From 27b289ee3b30ed3b0643a1c78c2e8caec329dcd2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 1 Mar 2016 11:23:03 -0500 Subject: [PATCH] Initial push to public repo --- .gitignore | 6 + LICENSE.txt | 177 + README.md | 51 + docs/data-model/circuits.md | 30 + docs/data-model/dcim.md | 84 + docs/data-model/ipam.md | 80 + docs/data-model/secrets.md | 23 + docs/schema.sql | 4145 +++++++++++++++++ netbox/circuits/__init__.py | 0 netbox/circuits/admin.py | 30 + netbox/circuits/api/__init__.py | 0 netbox/circuits/api/serializers.py | 60 + netbox/circuits/api/urls.py | 24 + netbox/circuits/api/views.py | 54 + netbox/circuits/filters.py | 52 + netbox/circuits/forms.py | 191 + netbox/circuits/migrations/0001_initial.py | 82 + netbox/circuits/migrations/__init__.py | 0 netbox/circuits/models.py | 86 + netbox/circuits/tables.py | 59 + netbox/circuits/tests.py | 3 + netbox/circuits/urls.py | 23 + netbox/circuits/views.py | 309 ++ netbox/dcim/__init__.py | 1 + netbox/dcim/admin.py | 161 + netbox/dcim/api/__init__.py | 0 netbox/dcim/api/exceptions.py | 6 + netbox/dcim/api/serializers.py | 300 ++ netbox/dcim/api/tests.py | 581 +++ netbox/dcim/api/urls.py | 66 + netbox/dcim/api/views.py | 438 ++ netbox/dcim/apps.py | 6 + netbox/dcim/filters.py | 317 ++ netbox/dcim/fixtures/dcim.yaml | 1794 +++++++ netbox/dcim/forms.py | 953 ++++ netbox/dcim/migrations/0001_initial.py | 291 ++ .../migrations/0002_auto_20160227_0235.py | 114 + netbox/dcim/migrations/__init__.py | 0 netbox/dcim/models.py | 686 +++ netbox/dcim/tables.py | 165 + netbox/dcim/tests/__init__.py | 0 netbox/dcim/tests/test_forms.py | 71 + netbox/dcim/tests/test_models.py | 96 + netbox/dcim/urls.py | 86 + netbox/dcim/views.py | 1444 ++++++ netbox/extras/__init__.py | 0 netbox/extras/admin.py | 13 + netbox/extras/api/__init__.py | 0 netbox/extras/api/renderers.py | 31 + netbox/extras/api/serializers.py | 14 + netbox/extras/api/views.py | 33 + netbox/extras/fixtures/extras.yaml | 12 + netbox/extras/management/__init__.py | 0 netbox/extras/management/commands/__init__.py | 0 .../management/commands/run_inventory.py | 117 + netbox/extras/migrations/0001_initial.py | 50 + netbox/extras/migrations/__init__.py | 0 netbox/extras/models.py | 70 + netbox/extras/rpc.py | 247 + netbox/extras/tests.py | 3 + netbox/extras/views.py | 3 + netbox/ipam/__init__.py | 2 + netbox/ipam/admin.py | 74 + netbox/ipam/api/__init__.py | 0 netbox/ipam/api/serializers.py | 158 + netbox/ipam/api/urls.py | 40 + netbox/ipam/api/views.py | 140 + netbox/ipam/apps.py | 6 + netbox/ipam/fields.py | 82 + netbox/ipam/filters.py | 216 + netbox/ipam/fixtures/ipam.yaml | 98 + netbox/ipam/formfields.py | 30 + netbox/ipam/forms.py | 408 ++ netbox/ipam/lookups.py | 89 + netbox/ipam/migrations/0001_initial.py | 166 + netbox/ipam/migrations/__init__.py | 0 netbox/ipam/models.py | 275 ++ netbox/ipam/tables.py | 210 + netbox/ipam/tests.py | 3 + netbox/ipam/urls.py | 51 + netbox/ipam/views.py | 899 ++++ netbox/manage.py | 10 + netbox/netbox/__init__.py | 0 netbox/netbox/configuration.example.py | 36 + netbox/netbox/settings.py | 140 + netbox/netbox/urls.py | 32 + netbox/netbox/views.py | 45 + netbox/netbox/wsgi.py | 16 + netbox/project-static/css/base.css | 224 + netbox/project-static/js/forms.js | 116 + netbox/project-static/js/livesearch.js | 40 + netbox/project-static/js/secrets.js | 96 + netbox/secrets/__init__.py | 0 netbox/secrets/admin.py | 71 + netbox/secrets/api/__init__.py | 0 netbox/secrets/api/serializers.py | 39 + netbox/secrets/api/urls.py | 20 + netbox/secrets/api/views.py | 104 + netbox/secrets/apps.py | 7 + netbox/secrets/decorators.py | 24 + netbox/secrets/filters.py | 21 + netbox/secrets/forms.py | 146 + netbox/secrets/migrations/0001_initial.py | 68 + netbox/secrets/migrations/__init__.py | 0 netbox/secrets/models.py | 282 ++ netbox/secrets/tables.py | 31 + netbox/secrets/templates/activate_keys.html | 12 + netbox/secrets/tests/__init__.py | 1 + netbox/secrets/tests/test_models.py | 131 + netbox/secrets/urls.py | 13 + netbox/secrets/views.py | 235 + netbox/templates/500.html | 29 + netbox/templates/_base.html | 197 + netbox/templates/circuits/circuit.html | 102 + .../circuits/circuit_bulk_delete.html | 15 + .../templates/circuits/circuit_bulk_edit.html | 17 + netbox/templates/circuits/circuit_delete.html | 8 + netbox/templates/circuits/circuit_edit.html | 89 + netbox/templates/circuits/circuit_import.html | 82 + netbox/templates/circuits/circuit_list.html | 54 + .../templates/circuits/inc/circuit_table.html | 21 + .../circuits/inc/provider_table.html | 21 + netbox/templates/circuits/provider.html | 104 + .../circuits/provider_bulk_delete.html | 15 + .../circuits/provider_bulk_edit.html | 15 + .../templates/circuits/provider_delete.html | 8 + netbox/templates/circuits/provider_edit.html | 69 + .../templates/circuits/provider_import.html | 62 + netbox/templates/circuits/provider_list.html | 33 + netbox/templates/dcim/_rack_elevation.html | 49 + .../dcim/console_connections_import.html | 61 + .../dcim/console_connections_list.html | 31 + .../templates/dcim/consoleport_connect.html | 53 + netbox/templates/dcim/consoleport_delete.html | 8 + .../dcim/consoleport_disconnect.html | 8 + netbox/templates/dcim/consoleport_edit.html | 51 + .../dcim/consoleserverport_connect.html | 53 + .../dcim/consoleserverport_delete.html | 8 + .../dcim/consoleserverport_disconnect.html | 8 + .../dcim/consoleserverport_edit.html | 51 + netbox/templates/dcim/device.html | 426 ++ netbox/templates/dcim/device_bulk_delete.html | 15 + netbox/templates/dcim/device_bulk_edit.html | 16 + netbox/templates/dcim/device_delete.html | 8 + netbox/templates/dcim/device_edit.html | 84 + netbox/templates/dcim/device_import.html | 85 + netbox/templates/dcim/device_inventory.html | 66 + netbox/templates/dcim/device_list.html | 58 + .../templates/dcim/device_lldp_neighbors.html | 73 + netbox/templates/dcim/inc/_consoleport.html | 51 + .../dcim/inc/_consoleserverport.html | 51 + netbox/templates/dcim/inc/_device_header.html | 44 + netbox/templates/dcim/inc/_interface.html | 69 + netbox/templates/dcim/inc/_ipaddress.html | 18 + netbox/templates/dcim/inc/_poweroutlet.html | 51 + netbox/templates/dcim/inc/_powerport.html | 51 + netbox/templates/dcim/inc/device_table.html | 27 + netbox/templates/dcim/inc/rack_table.html | 21 + netbox/templates/dcim/interface_bulk_add.html | 18 + .../dcim/interface_connections_import.html | 61 + .../dcim/interface_connections_list.html | 31 + netbox/templates/dcim/interface_delete.html | 8 + netbox/templates/dcim/interface_edit.html | 51 + .../dcim/interfaceconnection_delete.html | 12 + .../dcim/interfaceconnection_edit.html | 78 + netbox/templates/dcim/ipaddress_assign.html | 37 + .../dcim/power_connections_import.html | 61 + .../dcim/power_connections_list.html | 31 + .../templates/dcim/poweroutlet_connect.html | 53 + netbox/templates/dcim/poweroutlet_delete.html | 8 + .../dcim/poweroutlet_disconnect.html | 8 + netbox/templates/dcim/poweroutlet_edit.html | 51 + netbox/templates/dcim/powerport_connect.html | 53 + netbox/templates/dcim/powerport_delete.html | 8 + .../templates/dcim/powerport_disconnect.html | 8 + netbox/templates/dcim/powerport_edit.html | 51 + netbox/templates/dcim/rack.html | 147 + netbox/templates/dcim/rack_bulk_delete.html | 15 + netbox/templates/dcim/rack_bulk_edit.html | 15 + netbox/templates/dcim/rack_delete.html | 8 + netbox/templates/dcim/rack_edit.html | 61 + netbox/templates/dcim/rack_import.html | 62 + netbox/templates/dcim/rack_list.html | 58 + netbox/templates/dcim/site.html | 163 + netbox/templates/dcim/site_delete.html | 8 + netbox/templates/dcim/site_edit.html | 62 + netbox/templates/dcim/site_import.html | 57 + netbox/templates/dcim/site_list.html | 30 + netbox/templates/home.html | 146 + netbox/templates/import_success.html | 13 + netbox/templates/inc/filter_panel.html | 26 + netbox/templates/ipam/aggregate.html | 77 + .../templates/ipam/aggregate_bulk_delete.html | 15 + .../templates/ipam/aggregate_bulk_edit.html | 15 + netbox/templates/ipam/aggregate_delete.html | 8 + netbox/templates/ipam/aggregate_edit.html | 48 + netbox/templates/ipam/aggregate_import.html | 57 + netbox/templates/ipam/aggregate_list.html | 37 + .../templates/ipam/inc/aggregate_table.html | 21 + .../templates/ipam/inc/ipaddress_table.html | 21 + netbox/templates/ipam/inc/prefix_header.html | 44 + netbox/templates/ipam/inc/prefix_table.html | 21 + netbox/templates/ipam/inc/vlan_table.html | 21 + netbox/templates/ipam/inc/vrf_table.html | 21 + netbox/templates/ipam/ipaddress.html | 138 + .../templates/ipam/ipaddress_bulk_delete.html | 15 + .../templates/ipam/ipaddress_bulk_edit.html | 16 + netbox/templates/ipam/ipaddress_delete.html | 8 + netbox/templates/ipam/ipaddress_edit.html | 89 + netbox/templates/ipam/ipaddress_import.html | 67 + netbox/templates/ipam/ipaddress_list.html | 69 + netbox/templates/ipam/prefix.html | 92 + netbox/templates/ipam/prefix_bulk_delete.html | 15 + netbox/templates/ipam/prefix_bulk_edit.html | 17 + netbox/templates/ipam/prefix_delete.html | 8 + netbox/templates/ipam/prefix_edit.html | 51 + netbox/templates/ipam/prefix_import.html | 67 + netbox/templates/ipam/prefix_ipaddresses.html | 13 + netbox/templates/ipam/prefix_list.html | 59 + netbox/templates/ipam/vlan.html | 99 + netbox/templates/ipam/vlan_bulk_delete.html | 15 + netbox/templates/ipam/vlan_bulk_edit.html | 16 + netbox/templates/ipam/vlan_delete.html | 22 + netbox/templates/ipam/vlan_edit.html | 49 + netbox/templates/ipam/vlan_import.html | 62 + netbox/templates/ipam/vlan_list.html | 59 + netbox/templates/ipam/vrf.html | 74 + netbox/templates/ipam/vrf_bulk_delete.html | 15 + netbox/templates/ipam/vrf_bulk_edit.html | 14 + netbox/templates/ipam/vrf_delete.html | 22 + netbox/templates/ipam/vrf_edit.html | 45 + netbox/templates/ipam/vrf_import.html | 52 + netbox/templates/ipam/vrf_list.html | 58 + netbox/templates/login.html | 32 + netbox/templates/paginator.html | 35 + netbox/templates/panel_table.html | 25 + .../secrets/inc/private_key_modal.html | 25 + .../templates/secrets/inc/secret_table.html | 21 + netbox/templates/secrets/inc/secret_tr.html | 13 + netbox/templates/secrets/secret.html | 99 + .../templates/secrets/secret_bulk_delete.html | 13 + .../templates/secrets/secret_bulk_edit.html | 15 + netbox/templates/secrets/secret_delete.html | 8 + netbox/templates/secrets/secret_edit.html | 83 + netbox/templates/secrets/secret_import.html | 71 + netbox/templates/secrets/secret_list.html | 24 + netbox/templates/table.html | 8 + netbox/templates/users/change_password.html | 46 + netbox/templates/users/inc/profile_nav.html | 5 + netbox/templates/users/profile.html | 31 + netbox/templates/users/userkey.html | 45 + netbox/templates/users/userkey_edit.html | 72 + .../templates/utilities/bulk_edit_form.html | 44 + .../utilities/confirmation_form.html | 33 + netbox/templates/utilities/render_field.html | 52 + netbox/templates/utilities/render_form.html | 8 + netbox/users/__init__.py | 0 netbox/users/admin.py | 3 + netbox/users/forms.py | 16 + netbox/users/migrations/0001_initial.py | 14 + netbox/users/migrations/__init__.py | 0 netbox/users/models.py | 3 + netbox/users/tests.py | 3 + netbox/users/urls.py | 10 + netbox/users/views.py | 117 + netbox/utilities/__init__.py | 0 netbox/utilities/api.py | 6 + netbox/utilities/context_processors.py | 7 + netbox/utilities/error_handlers.py | 29 + netbox/utilities/fields.py | 14 + netbox/utilities/forms.py | 248 + netbox/utilities/middleware.py | 15 + netbox/utilities/migrations/0001_initial.py | 14 + netbox/utilities/migrations/__init__.py | 0 netbox/utilities/models.py | 3 + netbox/utilities/paginator.py | 30 + netbox/utilities/templatetags/__init__.py | 0 netbox/utilities/templatetags/form_helpers.py | 35 + netbox/utilities/templatetags/helpers.py | 71 + netbox/utilities/views.py | 178 + requirements.txt | 25 + 281 files changed, 26061 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 docs/data-model/circuits.md create mode 100644 docs/data-model/dcim.md create mode 100644 docs/data-model/ipam.md create mode 100644 docs/data-model/secrets.md create mode 100644 docs/schema.sql create mode 100644 netbox/circuits/__init__.py create mode 100644 netbox/circuits/admin.py create mode 100644 netbox/circuits/api/__init__.py create mode 100644 netbox/circuits/api/serializers.py create mode 100644 netbox/circuits/api/urls.py create mode 100644 netbox/circuits/api/views.py create mode 100644 netbox/circuits/filters.py create mode 100644 netbox/circuits/forms.py create mode 100644 netbox/circuits/migrations/0001_initial.py create mode 100644 netbox/circuits/migrations/__init__.py create mode 100644 netbox/circuits/models.py create mode 100644 netbox/circuits/tables.py create mode 100644 netbox/circuits/tests.py create mode 100644 netbox/circuits/urls.py create mode 100644 netbox/circuits/views.py create mode 100644 netbox/dcim/__init__.py create mode 100644 netbox/dcim/admin.py create mode 100644 netbox/dcim/api/__init__.py create mode 100644 netbox/dcim/api/exceptions.py create mode 100644 netbox/dcim/api/serializers.py create mode 100644 netbox/dcim/api/tests.py create mode 100644 netbox/dcim/api/urls.py create mode 100644 netbox/dcim/api/views.py create mode 100644 netbox/dcim/apps.py create mode 100644 netbox/dcim/filters.py create mode 100644 netbox/dcim/fixtures/dcim.yaml create mode 100644 netbox/dcim/forms.py create mode 100644 netbox/dcim/migrations/0001_initial.py create mode 100644 netbox/dcim/migrations/0002_auto_20160227_0235.py create mode 100644 netbox/dcim/migrations/__init__.py create mode 100644 netbox/dcim/models.py create mode 100644 netbox/dcim/tables.py create mode 100644 netbox/dcim/tests/__init__.py create mode 100644 netbox/dcim/tests/test_forms.py create mode 100644 netbox/dcim/tests/test_models.py create mode 100644 netbox/dcim/urls.py create mode 100644 netbox/dcim/views.py create mode 100644 netbox/extras/__init__.py create mode 100644 netbox/extras/admin.py create mode 100644 netbox/extras/api/__init__.py create mode 100644 netbox/extras/api/renderers.py create mode 100644 netbox/extras/api/serializers.py create mode 100644 netbox/extras/api/views.py create mode 100644 netbox/extras/fixtures/extras.yaml create mode 100644 netbox/extras/management/__init__.py create mode 100644 netbox/extras/management/commands/__init__.py create mode 100644 netbox/extras/management/commands/run_inventory.py create mode 100644 netbox/extras/migrations/0001_initial.py create mode 100644 netbox/extras/migrations/__init__.py create mode 100644 netbox/extras/models.py create mode 100644 netbox/extras/rpc.py create mode 100644 netbox/extras/tests.py create mode 100644 netbox/extras/views.py create mode 100644 netbox/ipam/__init__.py create mode 100644 netbox/ipam/admin.py create mode 100644 netbox/ipam/api/__init__.py create mode 100644 netbox/ipam/api/serializers.py create mode 100644 netbox/ipam/api/urls.py create mode 100644 netbox/ipam/api/views.py create mode 100644 netbox/ipam/apps.py create mode 100644 netbox/ipam/fields.py create mode 100644 netbox/ipam/filters.py create mode 100644 netbox/ipam/fixtures/ipam.yaml create mode 100644 netbox/ipam/formfields.py create mode 100644 netbox/ipam/forms.py create mode 100644 netbox/ipam/lookups.py create mode 100644 netbox/ipam/migrations/0001_initial.py create mode 100644 netbox/ipam/migrations/__init__.py create mode 100644 netbox/ipam/models.py create mode 100644 netbox/ipam/tables.py create mode 100644 netbox/ipam/tests.py create mode 100644 netbox/ipam/urls.py create mode 100644 netbox/ipam/views.py create mode 100755 netbox/manage.py create mode 100644 netbox/netbox/__init__.py create mode 100644 netbox/netbox/configuration.example.py create mode 100644 netbox/netbox/settings.py create mode 100644 netbox/netbox/urls.py create mode 100644 netbox/netbox/views.py create mode 100644 netbox/netbox/wsgi.py create mode 100644 netbox/project-static/css/base.css create mode 100644 netbox/project-static/js/forms.js create mode 100644 netbox/project-static/js/livesearch.js create mode 100644 netbox/project-static/js/secrets.js create mode 100644 netbox/secrets/__init__.py create mode 100644 netbox/secrets/admin.py create mode 100644 netbox/secrets/api/__init__.py create mode 100644 netbox/secrets/api/serializers.py create mode 100644 netbox/secrets/api/urls.py create mode 100644 netbox/secrets/api/views.py create mode 100644 netbox/secrets/apps.py create mode 100644 netbox/secrets/decorators.py create mode 100644 netbox/secrets/filters.py create mode 100644 netbox/secrets/forms.py create mode 100644 netbox/secrets/migrations/0001_initial.py create mode 100644 netbox/secrets/migrations/__init__.py create mode 100644 netbox/secrets/models.py create mode 100644 netbox/secrets/tables.py create mode 100644 netbox/secrets/templates/activate_keys.html create mode 100644 netbox/secrets/tests/__init__.py create mode 100644 netbox/secrets/tests/test_models.py create mode 100644 netbox/secrets/urls.py create mode 100644 netbox/secrets/views.py create mode 100644 netbox/templates/500.html create mode 100644 netbox/templates/_base.html create mode 100644 netbox/templates/circuits/circuit.html create mode 100644 netbox/templates/circuits/circuit_bulk_delete.html create mode 100644 netbox/templates/circuits/circuit_bulk_edit.html create mode 100644 netbox/templates/circuits/circuit_delete.html create mode 100644 netbox/templates/circuits/circuit_edit.html create mode 100644 netbox/templates/circuits/circuit_import.html create mode 100644 netbox/templates/circuits/circuit_list.html create mode 100644 netbox/templates/circuits/inc/circuit_table.html create mode 100644 netbox/templates/circuits/inc/provider_table.html create mode 100644 netbox/templates/circuits/provider.html create mode 100644 netbox/templates/circuits/provider_bulk_delete.html create mode 100644 netbox/templates/circuits/provider_bulk_edit.html create mode 100644 netbox/templates/circuits/provider_delete.html create mode 100644 netbox/templates/circuits/provider_edit.html create mode 100644 netbox/templates/circuits/provider_import.html create mode 100644 netbox/templates/circuits/provider_list.html create mode 100644 netbox/templates/dcim/_rack_elevation.html create mode 100644 netbox/templates/dcim/console_connections_import.html create mode 100644 netbox/templates/dcim/console_connections_list.html create mode 100644 netbox/templates/dcim/consoleport_connect.html create mode 100644 netbox/templates/dcim/consoleport_delete.html create mode 100644 netbox/templates/dcim/consoleport_disconnect.html create mode 100644 netbox/templates/dcim/consoleport_edit.html create mode 100644 netbox/templates/dcim/consoleserverport_connect.html create mode 100644 netbox/templates/dcim/consoleserverport_delete.html create mode 100644 netbox/templates/dcim/consoleserverport_disconnect.html create mode 100644 netbox/templates/dcim/consoleserverport_edit.html create mode 100644 netbox/templates/dcim/device.html create mode 100644 netbox/templates/dcim/device_bulk_delete.html create mode 100644 netbox/templates/dcim/device_bulk_edit.html create mode 100644 netbox/templates/dcim/device_delete.html create mode 100644 netbox/templates/dcim/device_edit.html create mode 100644 netbox/templates/dcim/device_import.html create mode 100644 netbox/templates/dcim/device_inventory.html create mode 100644 netbox/templates/dcim/device_list.html create mode 100644 netbox/templates/dcim/device_lldp_neighbors.html create mode 100644 netbox/templates/dcim/inc/_consoleport.html create mode 100644 netbox/templates/dcim/inc/_consoleserverport.html create mode 100644 netbox/templates/dcim/inc/_device_header.html create mode 100644 netbox/templates/dcim/inc/_interface.html create mode 100644 netbox/templates/dcim/inc/_ipaddress.html create mode 100644 netbox/templates/dcim/inc/_poweroutlet.html create mode 100644 netbox/templates/dcim/inc/_powerport.html create mode 100644 netbox/templates/dcim/inc/device_table.html create mode 100644 netbox/templates/dcim/inc/rack_table.html create mode 100644 netbox/templates/dcim/interface_bulk_add.html create mode 100644 netbox/templates/dcim/interface_connections_import.html create mode 100644 netbox/templates/dcim/interface_connections_list.html create mode 100644 netbox/templates/dcim/interface_delete.html create mode 100644 netbox/templates/dcim/interface_edit.html create mode 100644 netbox/templates/dcim/interfaceconnection_delete.html create mode 100644 netbox/templates/dcim/interfaceconnection_edit.html create mode 100644 netbox/templates/dcim/ipaddress_assign.html create mode 100644 netbox/templates/dcim/power_connections_import.html create mode 100644 netbox/templates/dcim/power_connections_list.html create mode 100644 netbox/templates/dcim/poweroutlet_connect.html create mode 100644 netbox/templates/dcim/poweroutlet_delete.html create mode 100644 netbox/templates/dcim/poweroutlet_disconnect.html create mode 100644 netbox/templates/dcim/poweroutlet_edit.html create mode 100644 netbox/templates/dcim/powerport_connect.html create mode 100644 netbox/templates/dcim/powerport_delete.html create mode 100644 netbox/templates/dcim/powerport_disconnect.html create mode 100644 netbox/templates/dcim/powerport_edit.html create mode 100644 netbox/templates/dcim/rack.html create mode 100644 netbox/templates/dcim/rack_bulk_delete.html create mode 100644 netbox/templates/dcim/rack_bulk_edit.html create mode 100644 netbox/templates/dcim/rack_delete.html create mode 100644 netbox/templates/dcim/rack_edit.html create mode 100644 netbox/templates/dcim/rack_import.html create mode 100644 netbox/templates/dcim/rack_list.html create mode 100644 netbox/templates/dcim/site.html create mode 100644 netbox/templates/dcim/site_delete.html create mode 100644 netbox/templates/dcim/site_edit.html create mode 100644 netbox/templates/dcim/site_import.html create mode 100644 netbox/templates/dcim/site_list.html create mode 100644 netbox/templates/home.html create mode 100644 netbox/templates/import_success.html create mode 100644 netbox/templates/inc/filter_panel.html create mode 100644 netbox/templates/ipam/aggregate.html create mode 100644 netbox/templates/ipam/aggregate_bulk_delete.html create mode 100644 netbox/templates/ipam/aggregate_bulk_edit.html create mode 100644 netbox/templates/ipam/aggregate_delete.html create mode 100644 netbox/templates/ipam/aggregate_edit.html create mode 100644 netbox/templates/ipam/aggregate_import.html create mode 100644 netbox/templates/ipam/aggregate_list.html create mode 100644 netbox/templates/ipam/inc/aggregate_table.html create mode 100644 netbox/templates/ipam/inc/ipaddress_table.html create mode 100644 netbox/templates/ipam/inc/prefix_header.html create mode 100644 netbox/templates/ipam/inc/prefix_table.html create mode 100644 netbox/templates/ipam/inc/vlan_table.html create mode 100644 netbox/templates/ipam/inc/vrf_table.html create mode 100644 netbox/templates/ipam/ipaddress.html create mode 100644 netbox/templates/ipam/ipaddress_bulk_delete.html create mode 100644 netbox/templates/ipam/ipaddress_bulk_edit.html create mode 100644 netbox/templates/ipam/ipaddress_delete.html create mode 100644 netbox/templates/ipam/ipaddress_edit.html create mode 100644 netbox/templates/ipam/ipaddress_import.html create mode 100644 netbox/templates/ipam/ipaddress_list.html create mode 100644 netbox/templates/ipam/prefix.html create mode 100644 netbox/templates/ipam/prefix_bulk_delete.html create mode 100644 netbox/templates/ipam/prefix_bulk_edit.html create mode 100644 netbox/templates/ipam/prefix_delete.html create mode 100644 netbox/templates/ipam/prefix_edit.html create mode 100644 netbox/templates/ipam/prefix_import.html create mode 100644 netbox/templates/ipam/prefix_ipaddresses.html create mode 100644 netbox/templates/ipam/prefix_list.html create mode 100644 netbox/templates/ipam/vlan.html create mode 100644 netbox/templates/ipam/vlan_bulk_delete.html create mode 100644 netbox/templates/ipam/vlan_bulk_edit.html create mode 100644 netbox/templates/ipam/vlan_delete.html create mode 100644 netbox/templates/ipam/vlan_edit.html create mode 100644 netbox/templates/ipam/vlan_import.html create mode 100644 netbox/templates/ipam/vlan_list.html create mode 100644 netbox/templates/ipam/vrf.html create mode 100644 netbox/templates/ipam/vrf_bulk_delete.html create mode 100644 netbox/templates/ipam/vrf_bulk_edit.html create mode 100644 netbox/templates/ipam/vrf_delete.html create mode 100644 netbox/templates/ipam/vrf_edit.html create mode 100644 netbox/templates/ipam/vrf_import.html create mode 100644 netbox/templates/ipam/vrf_list.html create mode 100644 netbox/templates/login.html create mode 100644 netbox/templates/paginator.html create mode 100644 netbox/templates/panel_table.html create mode 100644 netbox/templates/secrets/inc/private_key_modal.html create mode 100644 netbox/templates/secrets/inc/secret_table.html create mode 100644 netbox/templates/secrets/inc/secret_tr.html create mode 100644 netbox/templates/secrets/secret.html create mode 100644 netbox/templates/secrets/secret_bulk_delete.html create mode 100644 netbox/templates/secrets/secret_bulk_edit.html create mode 100644 netbox/templates/secrets/secret_delete.html create mode 100644 netbox/templates/secrets/secret_edit.html create mode 100644 netbox/templates/secrets/secret_import.html create mode 100644 netbox/templates/secrets/secret_list.html create mode 100644 netbox/templates/table.html create mode 100644 netbox/templates/users/change_password.html create mode 100644 netbox/templates/users/inc/profile_nav.html create mode 100644 netbox/templates/users/profile.html create mode 100644 netbox/templates/users/userkey.html create mode 100644 netbox/templates/users/userkey_edit.html create mode 100644 netbox/templates/utilities/bulk_edit_form.html create mode 100644 netbox/templates/utilities/confirmation_form.html create mode 100644 netbox/templates/utilities/render_field.html create mode 100644 netbox/templates/utilities/render_form.html create mode 100644 netbox/users/__init__.py create mode 100644 netbox/users/admin.py create mode 100644 netbox/users/forms.py create mode 100644 netbox/users/migrations/0001_initial.py create mode 100644 netbox/users/migrations/__init__.py create mode 100644 netbox/users/models.py create mode 100644 netbox/users/tests.py create mode 100644 netbox/users/urls.py create mode 100644 netbox/users/views.py create mode 100644 netbox/utilities/__init__.py create mode 100644 netbox/utilities/api.py create mode 100644 netbox/utilities/context_processors.py create mode 100644 netbox/utilities/error_handlers.py create mode 100644 netbox/utilities/fields.py create mode 100644 netbox/utilities/forms.py create mode 100644 netbox/utilities/middleware.py create mode 100644 netbox/utilities/migrations/0001_initial.py create mode 100644 netbox/utilities/migrations/__init__.py create mode 100644 netbox/utilities/models.py create mode 100644 netbox/utilities/paginator.py create mode 100644 netbox/utilities/templatetags/__init__.py create mode 100644 netbox/utilities/templatetags/form_helpers.py create mode 100644 netbox/utilities/templatetags/helpers.py create mode 100644 netbox/utilities/views.py create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..e8ff56275 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +*.pyc +configuration.py +.idea +*.sh +fabfile.py + diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 000000000..f433b1a53 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 000000000..4a9ced76f --- /dev/null +++ b/README.md @@ -0,0 +1,51 @@ +NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. + +NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](http://www.postgresql.org/) database. For a complete list of requirements, see `requirements.txt`. + +# Components + +NetBox understands all of the physical and logical building blocks that comprise network infrastructure, and the manners in which they are all related. + +## DCIM + +DCIM comprises all the physical installations and connections which comprise a network. NetBox tracks where devices are installed, as well as their individual power, console, and network connections. + +**Site:** A physical location (typically a building) where network devices are installed. Devices in different sites cannot be directly connected to one another. + +**Rack:** An equipment rack into which devices are installed. Each rack belongs to a site. + +**Device:** Any type of rack-mounted device. For example, routers, switches, servers, console servers, PDUs, etc. 0U (non-rack-mounted) devices are supported. + +## IPAM + +IPAM deals with the IP addressing and VLANs in use on a network. NetBox makes a distinction between IP prefixes (networks) and individual IP addresses. + +Because NetBox is a combined DCIM/IPAM system, IP addresses can be assigned to device interfaces in the application just as they are in the real world. + +**Aggregate:** A top-level aggregate of IP address space; for example, 10.0.0.0/8 or 2001:db8::/32. Each aggregate belongs to a regional Internet registry (RIR) like ARIN or RIPE, or to an authoritative standard such as RFC 1918. + +**VRF:** A virtual routing table. VRF support is currently still under development. + +**Prefix:** An IPv4 or IPv6 network. A prefix can be assigned to a VRF; if not, it is considered to belong to the global table. Prefixes are grouped by aggregates automatically and can optionally be assigned to sites. + +**IP Address:** An individual IPv4 or IPv6 address (with CIDR mask). IP address can be assigned to device interfaces. + +**VLAN:** VLANs are assigned to sites, and can optionally have one or more IP prefixes assigned to them. VLAN IDs are unique only within the scope of a site. + +## Circuits + +Long-distance data connections are typically referred to as _circuits_. NetBox provides a method for managing circuits and their providers. Individual circuits can be terminated to device interfaces. + +**Provider:** An entity to which a network connects to. This can be a transit provider, peer, or some other organization. + +**Circuit:** A data circuit which connects to a provider. The local end of a circuit can be assigned to a device interface. + +## Secrets + +NetBox provides encrypted storage of sensitive data it calls _secrets_. Each user may be issued an encryption key with which stored secrets can be retrieved. + +Note that NetBox does not merely hash secrets, a function which is only useful for validation. It employs fully reversible AES-256 encryption so that secret data can be retrieved and consumed by other services. + +**Secrets** Any piece of confidential data which must be retrievable. For example: passwords, SNMP communities, RADIUS shared secrets, etc. + +**User Key:** An individual user's encrypted copy of the master key, which can be used to retrieve secret data. diff --git a/docs/data-model/circuits.md b/docs/data-model/circuits.md new file mode 100644 index 000000000..5902890bc --- /dev/null +++ b/docs/data-model/circuits.md @@ -0,0 +1,30 @@ +Circuits are communication links which connect two endpoints, typically over long distances. For example, a circuit might connect an enterprise to its Internet service provider. NetBox can track circuits and their providers. + +# Providers + +A provider is any entity which provides some form of connectivity. This obviously includes carriers which offer Internet and private transit service. However, it might also include Internet exchange (IX) points and even organizations with whom you peer directly. + +Each provider may be assigned an autonomous system number (ASN) for reference. Each provider can also be assigned account and contact information, as well as miscellaneous comments. + +# Circuits + +A circuit represents a single physical data link connecting two endpoints. Each circuit belongs to a provider and must be assigned circuit ID which is unique to that provider. Each circuit must also be assigned to a site, and may optionally be connected to a specific interface on a specific device within that site. + +NetBox also tracks miscellaneous circuit attributes (most of which are optional), including: + +* Date of installation +* Port speed +* Commit rate +* Cross-connect ID +* Patch panel information + +## Circuit Type + +Circuits can be classified by type. For example: + +* Internet transit +* Out-of-band connectivity +* Peering +* Private backhaul + +Each circuit must be assigned exactly one circuit type. diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md new file mode 100644 index 000000000..ba91bb78c --- /dev/null +++ b/docs/data-model/dcim.md @@ -0,0 +1,84 @@ +The data center infrastructure management (DCIM) component of NetBox assists in the management of physical assets within a network: equipment racks, the gear in them, and the cabling that connects it all. + +# Sites + +A site is a geographic location at which network equipment is housed. How you choose to define sites will depend on the nature of your organization, but typically a site will be a building or campus. For example, a chain of banks might create a site to represent each of its branches, a site for its corporate headquarters, and two additional sites for its presence in two colocation facilities. + +# Racks + +Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units *(U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted. + +Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number. + +## Rack Groups + +Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room. + +Each group is assigned to a parent sire for easy navigation. Hierarchical recursion of rack groups is not currently supported. + +# Devices + +Every piece of hardware which is installed within a rack exists in NetBox as a device. Devices are measured in rack units (U) and whether they are full depth. 0U devices which can be installed in a rack but don't consume vertical rack space (such as a vertically-mounted power distribution unit) can also be defined. + +A device is said to be "full depth" if its installation on one rack face prevents the installation of any other device on the opposite face within the same rack unit(s). This could be either because the device is physically too deep to allow a device behind it, or because the installation of an opposing device would impede air flow. + +Each device has a physical device type (make and model), which is discussed below. + +## Device Roles + +NetBox allows for the definition of arbitrary device roles by which devices can be organized. For example, you might create roles for core switches, distribution switches, and access switches. In the interest of simplicity, device can only belong to one device role. + +## Platform + +A device's platform is used to denote the type of software running on it. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. + +The assignment of platforms to devices is an entirely optional feature, and may be disregarded if not desired. + +## Modules + +A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. + +## Device Components + +There are five types of device components which comprise all of the interconnection logic with NetBox: + +* Console ports +* Console server ports +* Power ports +* Power outlets +* Interfaces + +Console ports connect only to console server ports, and power ports connect only to power outlets. Interfaces connect to one another in a symmetric manner: If interface A connects to interface B, interface B therefore connects to interface A. (The relationship between two interfaces is actually represented in the database by an InterfaceConnection object, but this is transparent to the user.) + +Each type of connection can be defined as either *planned* or *connected*. This allows for easily denoting connections which have not yet been installed. + +In addition to a connecting peer, interfaces are also assigned a form factor and may be designated as management-only (for out-of-band management). Interfaces may also be assigned a short description. + +# Device Types + +A device type represents a particular manufacturer and model of equipment. Device types describe the physical attributes of a device (rack height and depth), its class (e.g. console server, PDU, etc.), and its individual components (console, power, and data). + +## Manufacturers + +Each device type belongs to one manufacturer; e.g. Cisco, Opengear, or APC. Manufacturers are used to group different models of device. + +## Device Component Templates + +Each device type is assigned a number of component templates which describe the console, power, and data ports a device has. These are: + +* Console port templates +* Console server port templates +* Power port templates +* Power outlet templates +* Interface templates + +Whenever a new device is created, it is automatically assigned console, power, and interface components per the templates assigned to its device type. For example, suppose your network employs Juniper EX4300-48T switches. You would create a device type with a model name "EX4300-48T" and assign it to the manufacturer "Juniper." You might then also create the following templates for it: + +* One template for a console port ("Console") +* Two templates for power ports ("PSU0" and "PSU1") +* 48 templates for 1GE interfaces ("ge-0/0/0" through "ge-0/0/47") +* Four templates for 10GE interfaces ("xe-0/2/0" through "xe-0/2/3") + +Once you've done this, every new device that you create as an instance of this type will automatically be assigned each of the components listed above. + +Note that assignment of components from templates occurs only at the time of device creation: If you modify the templates of a device type, it will not affect devices which have already been created. However, you always have the option of adding, modifying, or deleting components of existing devices individually. diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md new file mode 100644 index 000000000..c3f3b8d9b --- /dev/null +++ b/docs/data-model/ipam.md @@ -0,0 +1,80 @@ +IP address management (IPAM) entails the allocation of IP networks and addresses. Within NetBox, at least, IPAM also includes the management of VLAN assignments. + +# VRF + +A VRF object in NetBox represents a virtual routing and forwarding (VRF) domain within a network. Each VRF is essentially a separate routing table: the same IP prefix or address can exist in multiple VRFs. VRFs are commonly used to isolate customers or organizations from one another within a network. + +Each VRF is assigned a name and a unique route distinguisher (RD). VRFs are an optional feature of NetBox: Any IP prefix or address not assigned to a VRF is said to belong to the "global" table. + +# Aggregate + +IPv4 address space is organized as a hierarchy, with more-specific (smaller) prefix arranged as child nodes under less-specific (larger) prefixes. For example: + +* 10.0.0.0/8 + * 10.1.0.0/16 + * 10.1.2.0/24 + +The root of the IPv4 hierarchy is 0.0.0.0/0, which encompasses all possible IPv4 addresses (and similarly, ::/0 for IPv6). However, even the largest organizations use only a small fraction of the global address space. Therefore, it makes sense to track in NetBox only the address space which is of interest to your organization. + +Aggregates serve as arbitrary top-level nodes in the IP space hierarchy. They allow you to easily construct your IP scheme without any clutter of unused address space. For instance, most organizations utilize some portion of the RFC 1918 private IPv4 space. So, you might define three aggregates for this space: + +* 10.0.0.0/8 +* 172.16.0.0/12 +* 192.168.0.0/16 + +Additionally, you might define an aggregate for each large swath of public IPv4 space your organization uses. You'd also create aggregates for both globally routable and unique local IPv6 space. + +Any prefixes you create in NetBox (discussed below) will be automatically organized under their respective aggregates. Any space within an aggregate which is not covered by an existing prefix will be annotated as available for allocation. + +## RIRs + +Regional Internet Registries (RIRs) are responsible for the allocation of global address space. The five RIRs are ARIN, RIPE, APNIC, LACNIC, and AFRINIC. However, some address space has been set aside for private or internal use only, such as defined in RFCs 1918 and 6598. NetBox considers these RFCs as a sort of RIR as well; that is, an authority which "owns" certain address space. + +Each aggregate must be assigned to one RIR. NetBox by default will be populated with the RIRs listed above, however you are free to remove these and/or create your own if you choose. + +# Prefixes + +A prefix is an IPv4 or IPv6 network and mask expressed in CIDR notation (e.g. 192.0.2.0/24). A prefix entails only the "network portion" of an IP address; all bits in the address not covered by the mask must be zero. + +Each prefix may be assigned to one VRF; prefixes not assigned to a VRF are assigned to the "global" table. Prefixes are also organized under their respective aggregates, irrespective of VRF assignment. + +A prefix may optionally be assigned to one VLAN; a VLAN may have multiple prefixes assigned to it. This can be helpful is replicating real-world IP assignments. Each prefix may also be assigned a short description. + +## Status + +Each prefix is assigned an operational status. A status describes very generally the state of a prefix within the network; for example, statuses might include: + +* Active (provisioned) +* Reserved (for future use) +* Deprecated (no longer in use) +* Container (a summary of child prefixes) + +NetBox provides several statuses by default, but you are free to change them to suit the needs of your organization. + +## Role + +Whereas a status describes a prefix's operational state, a role describes its function. For example, roles might include: + +* Access segment +* Infrastructure +* NAT +* Lab +* Out-of-band + +Role assignment is optional. And like statuses, you are free to create your own. + +# IP Addresses + +An IP address comprises a single address (either IPv4 or IPv6) and its mask. Its mask should match exactly how the IP address is configured on an interface in the real world. + +Like prefixes, an IP address can optionally be assigned to a VRF (or it will appear in the "global" table). IP addresses are automatically organized under parent prefixes within their respective VRFs. Each IP address can also be assigned a short description. + +Each IP address can optionally be assigned to a device's interface; an interface may have multiple IP addresses assigned to it. Further, each device may have one of its interface IPs designated as its primary IP address. + +One IP address can be designated as the network address translation (NAT) IP address for exactly one other IP address. This is useful primarily is denoting the public address for a private internal IP. Tracking one-to-many NAT (or PAT) assignments is not currently supported. + +# VLAN + +A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice. + +Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. diff --git a/docs/data-model/secrets.md b/docs/data-model/secrets.md new file mode 100644 index 000000000..425e28df4 --- /dev/null +++ b/docs/data-model/secrets.md @@ -0,0 +1,23 @@ +"Secrets" are small amounts of data that must be kept confidential; for example, passwords and SNMP community strings. NetBox provides encrypted storage of secret data. + +# Secret + +A secret represents a single credential or other string which must be stored securely. Each secret is assigned to a parent object with NetBox, such as a device. The plaintext value of a secret is encrypted to a ciphertext immediately prior to storage within the database using a 256-bit AES master key. A SHA256 hash of the plaintext is also stored along with each ciphertext to validate the decrypted plaintext. + +Each secret can also store an optional name parameter, which is not encrypted. This may be useful for storing user names. + +## Secret Roles + +Each secret is assigned a functional role which indicates what it is used for. Typical roles might include: + +* Login credentials +* SNMP community strings +* RADIUS/TACACS+ keys +* IKE key strings +* Routing protocol shared secrets + +# User Keys + +Each user within NetBox can associate his or her account with an RSA public key. If activated by an administrator, this user key will contain a unique, encrypted copy of the AES master key needed to retrieve secret data. + +User keys may be created by users individually, however they are of no use until they have been activated by a user who already has access to retrieve secret data. \ No newline at end of file diff --git a/docs/schema.sql b/docs/schema.sql new file mode 100644 index 000000000..52f428bf1 --- /dev/null +++ b/docs/schema.sql @@ -0,0 +1,4145 @@ +-- +-- PostgreSQL database dump +-- + +SET statement_timeout = 0; +SET lock_timeout = 0; +SET client_encoding = 'UTF8'; +SET standard_conforming_strings = on; +SET check_function_bodies = false; +SET client_min_messages = warning; + +-- +-- Name: plpgsql; Type: EXTENSION; Schema: -; Owner: +-- + +CREATE EXTENSION IF NOT EXISTS plpgsql WITH SCHEMA pg_catalog; + + +-- +-- Name: EXTENSION plpgsql; Type: COMMENT; Schema: -; Owner: +-- + +COMMENT ON EXTENSION plpgsql IS 'PL/pgSQL procedural language'; + + +SET search_path = public, pg_catalog; + +SET default_tablespace = ''; + +SET default_with_oids = false; + +-- +-- Name: auth_group; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE auth_group ( + id integer NOT NULL, + name character varying(80) NOT NULL +); + + +ALTER TABLE public.auth_group OWNER TO django; + +-- +-- Name: auth_group_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE auth_group_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.auth_group_id_seq OWNER TO django; + +-- +-- Name: auth_group_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE auth_group_id_seq OWNED BY auth_group.id; + + +-- +-- Name: auth_group_permissions; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE auth_group_permissions ( + id integer NOT NULL, + group_id integer NOT NULL, + permission_id integer NOT NULL +); + + +ALTER TABLE public.auth_group_permissions OWNER TO django; + +-- +-- Name: auth_group_permissions_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE auth_group_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.auth_group_permissions_id_seq OWNER TO django; + +-- +-- Name: auth_group_permissions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE auth_group_permissions_id_seq OWNED BY auth_group_permissions.id; + + +-- +-- Name: auth_permission; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE auth_permission ( + id integer NOT NULL, + name character varying(255) NOT NULL, + content_type_id integer NOT NULL, + codename character varying(100) NOT NULL +); + + +ALTER TABLE public.auth_permission OWNER TO django; + +-- +-- Name: auth_permission_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE auth_permission_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.auth_permission_id_seq OWNER TO django; + +-- +-- Name: auth_permission_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE auth_permission_id_seq OWNED BY auth_permission.id; + + +-- +-- Name: auth_user; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE auth_user ( + id integer NOT NULL, + password character varying(128) NOT NULL, + last_login timestamp with time zone, + is_superuser boolean NOT NULL, + username character varying(30) NOT NULL, + first_name character varying(30) NOT NULL, + last_name character varying(30) NOT NULL, + email character varying(254) NOT NULL, + is_staff boolean NOT NULL, + is_active boolean NOT NULL, + date_joined timestamp with time zone NOT NULL +); + + +ALTER TABLE public.auth_user OWNER TO django; + +-- +-- Name: auth_user_groups; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE auth_user_groups ( + id integer NOT NULL, + user_id integer NOT NULL, + group_id integer NOT NULL +); + + +ALTER TABLE public.auth_user_groups OWNER TO django; + +-- +-- Name: auth_user_groups_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE auth_user_groups_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.auth_user_groups_id_seq OWNER TO django; + +-- +-- Name: auth_user_groups_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE auth_user_groups_id_seq OWNED BY auth_user_groups.id; + + +-- +-- Name: auth_user_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE auth_user_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.auth_user_id_seq OWNER TO django; + +-- +-- Name: auth_user_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE auth_user_id_seq OWNED BY auth_user.id; + + +-- +-- Name: auth_user_user_permissions; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE auth_user_user_permissions ( + id integer NOT NULL, + user_id integer NOT NULL, + permission_id integer NOT NULL +); + + +ALTER TABLE public.auth_user_user_permissions OWNER TO django; + +-- +-- Name: auth_user_user_permissions_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE auth_user_user_permissions_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.auth_user_user_permissions_id_seq OWNER TO django; + +-- +-- Name: auth_user_user_permissions_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE auth_user_user_permissions_id_seq OWNED BY auth_user_user_permissions.id; + + +-- +-- Name: cidr; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE cidr ( + id integer NOT NULL, + field pg_catalog.cidr NOT NULL +); + + +ALTER TABLE public.cidr OWNER TO django; + +-- +-- Name: cidr_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE cidr_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.cidr_id_seq OWNER TO django; + +-- +-- Name: cidr_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE cidr_id_seq OWNED BY cidr.id; + + +-- +-- Name: circuits_circuit; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE circuits_circuit ( + id integer NOT NULL, + cid character varying(50) NOT NULL, + install_date date, + port_speed smallint NOT NULL, + commit_rate integer, + comments text NOT NULL, + interface_id integer, + provider_id integer NOT NULL, + site_id integer NOT NULL, + xconnect_id character varying(50) NOT NULL, + type_id integer NOT NULL, + pp_info character varying(100) NOT NULL, + CONSTRAINT circuits_circuit_commit_rate_check CHECK ((commit_rate >= 0)), + CONSTRAINT circuits_circuit_port_speed_check CHECK ((port_speed >= 0)) +); + + +ALTER TABLE public.circuits_circuit OWNER TO django; + +-- +-- Name: circuits_circuit_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE circuits_circuit_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.circuits_circuit_id_seq OWNER TO django; + +-- +-- Name: circuits_circuit_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE circuits_circuit_id_seq OWNED BY circuits_circuit.id; + + +-- +-- Name: circuits_circuittype; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE circuits_circuittype ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL +); + + +ALTER TABLE public.circuits_circuittype OWNER TO django; + +-- +-- Name: circuits_circuittype_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE circuits_circuittype_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.circuits_circuittype_id_seq OWNER TO django; + +-- +-- Name: circuits_circuittype_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE circuits_circuittype_id_seq OWNED BY circuits_circuittype.id; + + +-- +-- Name: circuits_provider; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE circuits_provider ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + asn integer, + account character varying(30) NOT NULL, + portal_url character varying(200) NOT NULL, + noc_contact text NOT NULL, + admin_contact text NOT NULL, + comments text NOT NULL, + CONSTRAINT circuits_provider_asn_check CHECK ((asn >= 0)) +); + + +ALTER TABLE public.circuits_provider OWNER TO django; + +-- +-- Name: circuits_provider_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE circuits_provider_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.circuits_provider_id_seq OWNER TO django; + +-- +-- Name: circuits_provider_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE circuits_provider_id_seq OWNED BY circuits_provider.id; + + +-- +-- Name: corsheaders_corsmodel; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE corsheaders_corsmodel ( + id integer NOT NULL, + cors character varying(255) NOT NULL +); + + +ALTER TABLE public.corsheaders_corsmodel OWNER TO django; + +-- +-- Name: corsheaders_corsmodel_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE corsheaders_corsmodel_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.corsheaders_corsmodel_id_seq OWNER TO django; + +-- +-- Name: corsheaders_corsmodel_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE corsheaders_corsmodel_id_seq OWNED BY corsheaders_corsmodel.id; + + +-- +-- Name: dcim_consoleport; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_consoleport ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_id integer NOT NULL, + cs_port_id integer, + connection_status boolean +); + + +ALTER TABLE public.dcim_consoleport OWNER TO django; + +-- +-- Name: dcim_consoleport_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_consoleport_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_consoleport_id_seq OWNER TO django; + +-- +-- Name: dcim_consoleport_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_consoleport_id_seq OWNED BY dcim_consoleport.id; + + +-- +-- Name: dcim_consoleporttemplate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_consoleporttemplate ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_type_id integer NOT NULL +); + + +ALTER TABLE public.dcim_consoleporttemplate OWNER TO django; + +-- +-- Name: dcim_consoleporttemplate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_consoleporttemplate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_consoleporttemplate_id_seq OWNER TO django; + +-- +-- Name: dcim_consoleporttemplate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_consoleporttemplate_id_seq OWNED BY dcim_consoleporttemplate.id; + + +-- +-- Name: dcim_consoleserverport; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_consoleserverport ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_id integer NOT NULL +); + + +ALTER TABLE public.dcim_consoleserverport OWNER TO django; + +-- +-- Name: dcim_consoleserverport_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_consoleserverport_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_consoleserverport_id_seq OWNER TO django; + +-- +-- Name: dcim_consoleserverport_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_consoleserverport_id_seq OWNED BY dcim_consoleserverport.id; + + +-- +-- Name: dcim_consoleserverporttemplate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_consoleserverporttemplate ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_type_id integer NOT NULL +); + + +ALTER TABLE public.dcim_consoleserverporttemplate OWNER TO django; + +-- +-- Name: dcim_consoleserverporttemplate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_consoleserverporttemplate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_consoleserverporttemplate_id_seq OWNER TO django; + +-- +-- Name: dcim_consoleserverporttemplate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_consoleserverporttemplate_id_seq OWNED BY dcim_consoleserverporttemplate.id; + + +-- +-- Name: dcim_device; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_device ( + id integer NOT NULL, + name character varying(50), + serial character varying(50) NOT NULL, + "position" smallint, + face smallint, + device_type_id integer NOT NULL, + rack_id integer NOT NULL, + ro_snmp character varying(50) NOT NULL, + device_role_id integer NOT NULL, + primary_ip_id integer, + status boolean NOT NULL, + platform_id integer, + comments text NOT NULL, + CONSTRAINT dcim_device_face_check CHECK ((face >= 0)), + CONSTRAINT dcim_device_position_check CHECK (("position" >= 0)) +); + + +ALTER TABLE public.dcim_device OWNER TO django; + +-- +-- Name: dcim_device_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_device_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_device_id_seq OWNER TO django; + +-- +-- Name: dcim_device_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_device_id_seq OWNED BY dcim_device.id; + + +-- +-- Name: dcim_devicerole; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_devicerole ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + color character varying(30) NOT NULL +); + + +ALTER TABLE public.dcim_devicerole OWNER TO django; + +-- +-- Name: dcim_devicerole_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_devicerole_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_devicerole_id_seq OWNER TO django; + +-- +-- Name: dcim_devicerole_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_devicerole_id_seq OWNED BY dcim_devicerole.id; + + +-- +-- Name: dcim_devicetype; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_devicetype ( + id integer NOT NULL, + model character varying(50) NOT NULL, + u_height smallint NOT NULL, + manufacturer_id integer NOT NULL, + slug character varying(50) NOT NULL, + is_console_server boolean NOT NULL, + is_pdu boolean NOT NULL, + is_network_device boolean NOT NULL, + is_full_depth boolean NOT NULL, + CONSTRAINT dcim_devicetype_u_height_check CHECK ((u_height >= 0)) +); + + +ALTER TABLE public.dcim_devicetype OWNER TO django; + +-- +-- Name: dcim_devicetype_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_devicetype_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_devicetype_id_seq OWNER TO django; + +-- +-- Name: dcim_devicetype_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_devicetype_id_seq OWNED BY dcim_devicetype.id; + + +-- +-- Name: dcim_interface; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_interface ( + id integer NOT NULL, + name character varying(30) NOT NULL, + form_factor smallint NOT NULL, + mgmt_only boolean NOT NULL, + device_id integer NOT NULL, + description character varying(100) NOT NULL, + CONSTRAINT dcim_interface_form_factor_check CHECK ((form_factor >= 0)) +); + + +ALTER TABLE public.dcim_interface OWNER TO django; + +-- +-- Name: dcim_interface_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_interface_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_interface_id_seq OWNER TO django; + +-- +-- Name: dcim_interface_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_interface_id_seq OWNED BY dcim_interface.id; + + +-- +-- Name: dcim_interfaceconnection; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_interfaceconnection ( + id integer NOT NULL, + interface_a_id integer NOT NULL, + interface_b_id integer NOT NULL, + connection_status boolean NOT NULL +); + + +ALTER TABLE public.dcim_interfaceconnection OWNER TO django; + +-- +-- Name: dcim_interfaceconnection_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_interfaceconnection_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_interfaceconnection_id_seq OWNER TO django; + +-- +-- Name: dcim_interfaceconnection_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_interfaceconnection_id_seq OWNED BY dcim_interfaceconnection.id; + + +-- +-- Name: dcim_interfacetemplate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_interfacetemplate ( + id integer NOT NULL, + name character varying(30) NOT NULL, + form_factor smallint NOT NULL, + mgmt_only boolean NOT NULL, + device_type_id integer NOT NULL, + CONSTRAINT dcim_interfacetemplate_form_factor_check CHECK ((form_factor >= 0)) +); + + +ALTER TABLE public.dcim_interfacetemplate OWNER TO django; + +-- +-- Name: dcim_interfacetemplate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_interfacetemplate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_interfacetemplate_id_seq OWNER TO django; + +-- +-- Name: dcim_interfacetemplate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_interfacetemplate_id_seq OWNED BY dcim_interfacetemplate.id; + + +-- +-- Name: dcim_manufacturer; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_manufacturer ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL +); + + +ALTER TABLE public.dcim_manufacturer OWNER TO django; + +-- +-- Name: dcim_manufacturer_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_manufacturer_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_manufacturer_id_seq OWNER TO django; + +-- +-- Name: dcim_manufacturer_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_manufacturer_id_seq OWNED BY dcim_manufacturer.id; + + +-- +-- Name: dcim_module; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_module ( + id integer NOT NULL, + name character varying(50) NOT NULL, + part_id character varying(50) NOT NULL, + serial character varying(50) NOT NULL, + device_id integer NOT NULL +); + + +ALTER TABLE public.dcim_module OWNER TO django; + +-- +-- Name: dcim_module_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_module_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_module_id_seq OWNER TO django; + +-- +-- Name: dcim_module_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_module_id_seq OWNED BY dcim_module.id; + + +-- +-- Name: dcim_platform; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_platform ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + rpc_client character varying(30) NOT NULL +); + + +ALTER TABLE public.dcim_platform OWNER TO django; + +-- +-- Name: dcim_platform_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_platform_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_platform_id_seq OWNER TO django; + +-- +-- Name: dcim_platform_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_platform_id_seq OWNED BY dcim_platform.id; + + +-- +-- Name: dcim_poweroutlet; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_poweroutlet ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_id integer NOT NULL +); + + +ALTER TABLE public.dcim_poweroutlet OWNER TO django; + +-- +-- Name: dcim_poweroutlet_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_poweroutlet_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_poweroutlet_id_seq OWNER TO django; + +-- +-- Name: dcim_poweroutlet_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_poweroutlet_id_seq OWNED BY dcim_poweroutlet.id; + + +-- +-- Name: dcim_poweroutlettemplate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_poweroutlettemplate ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_type_id integer NOT NULL +); + + +ALTER TABLE public.dcim_poweroutlettemplate OWNER TO django; + +-- +-- Name: dcim_poweroutlettemplate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_poweroutlettemplate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_poweroutlettemplate_id_seq OWNER TO django; + +-- +-- Name: dcim_poweroutlettemplate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_poweroutlettemplate_id_seq OWNED BY dcim_poweroutlettemplate.id; + + +-- +-- Name: dcim_powerport; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_powerport ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_id integer NOT NULL, + power_outlet_id integer, + connection_status boolean +); + + +ALTER TABLE public.dcim_powerport OWNER TO django; + +-- +-- Name: dcim_powerport_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_powerport_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_powerport_id_seq OWNER TO django; + +-- +-- Name: dcim_powerport_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_powerport_id_seq OWNED BY dcim_powerport.id; + + +-- +-- Name: dcim_powerporttemplate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_powerporttemplate ( + id integer NOT NULL, + name character varying(30) NOT NULL, + device_type_id integer NOT NULL +); + + +ALTER TABLE public.dcim_powerporttemplate OWNER TO django; + +-- +-- Name: dcim_powerporttemplate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_powerporttemplate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_powerporttemplate_id_seq OWNER TO django; + +-- +-- Name: dcim_powerporttemplate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_powerporttemplate_id_seq OWNED BY dcim_powerporttemplate.id; + + +-- +-- Name: dcim_rack; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_rack ( + id integer NOT NULL, + name character varying(50) NOT NULL, + facility_id character varying(30), + u_height smallint NOT NULL, + site_id integer NOT NULL, + comments text NOT NULL, + group_id integer, + CONSTRAINT dcim_rack_u_height_check CHECK ((u_height >= 0)) +); + + +ALTER TABLE public.dcim_rack OWNER TO django; + +-- +-- Name: dcim_rack_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_rack_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_rack_id_seq OWNER TO django; + +-- +-- Name: dcim_rack_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_rack_id_seq OWNED BY dcim_rack.id; + + +-- +-- Name: dcim_rackgroup; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_rackgroup ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + site_id integer NOT NULL +); + + +ALTER TABLE public.dcim_rackgroup OWNER TO django; + +-- +-- Name: dcim_rackgroup_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_rackgroup_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_rackgroup_id_seq OWNER TO django; + +-- +-- Name: dcim_rackgroup_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_rackgroup_id_seq OWNED BY dcim_rackgroup.id; + + +-- +-- Name: dcim_site; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE dcim_site ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL, + facility character varying(50) NOT NULL, + asn integer, + physical_address character varying(200) NOT NULL, + shipping_address character varying(200) NOT NULL, + comments text NOT NULL, + CONSTRAINT dcim_site_asn_check CHECK ((asn >= 0)) +); + + +ALTER TABLE public.dcim_site OWNER TO django; + +-- +-- Name: dcim_site_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE dcim_site_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.dcim_site_id_seq OWNER TO django; + +-- +-- Name: dcim_site_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE dcim_site_id_seq OWNED BY dcim_site.id; + + +-- +-- Name: django_admin_log; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE django_admin_log ( + id integer NOT NULL, + action_time timestamp with time zone NOT NULL, + object_id text, + object_repr character varying(200) NOT NULL, + action_flag smallint NOT NULL, + change_message text NOT NULL, + content_type_id integer, + user_id integer NOT NULL, + CONSTRAINT django_admin_log_action_flag_check CHECK ((action_flag >= 0)) +); + + +ALTER TABLE public.django_admin_log OWNER TO django; + +-- +-- Name: django_admin_log_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE django_admin_log_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.django_admin_log_id_seq OWNER TO django; + +-- +-- Name: django_admin_log_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE django_admin_log_id_seq OWNED BY django_admin_log.id; + + +-- +-- Name: django_content_type; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE django_content_type ( + id integer NOT NULL, + app_label character varying(100) NOT NULL, + model character varying(100) NOT NULL +); + + +ALTER TABLE public.django_content_type OWNER TO django; + +-- +-- Name: django_content_type_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE django_content_type_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.django_content_type_id_seq OWNER TO django; + +-- +-- Name: django_content_type_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE django_content_type_id_seq OWNED BY django_content_type.id; + + +-- +-- Name: django_migrations; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE django_migrations ( + id integer NOT NULL, + app character varying(255) NOT NULL, + name character varying(255) NOT NULL, + applied timestamp with time zone NOT NULL +); + + +ALTER TABLE public.django_migrations OWNER TO django; + +-- +-- Name: django_migrations_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE django_migrations_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.django_migrations_id_seq OWNER TO django; + +-- +-- Name: django_migrations_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE django_migrations_id_seq OWNED BY django_migrations.id; + + +-- +-- Name: django_session; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE django_session ( + session_key character varying(40) NOT NULL, + session_data text NOT NULL, + expire_date timestamp with time zone NOT NULL +); + + +ALTER TABLE public.django_session OWNER TO django; + +-- +-- Name: extras_exporttemplate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE extras_exporttemplate ( + id integer NOT NULL, + name character varying(200) NOT NULL, + template_code text NOT NULL, + mime_type character varying(15) NOT NULL, + file_extension character varying(15) NOT NULL, + content_type_id integer NOT NULL +); + + +ALTER TABLE public.extras_exporttemplate OWNER TO django; + +-- +-- Name: extras_exporttemplate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE extras_exporttemplate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.extras_exporttemplate_id_seq OWNER TO django; + +-- +-- Name: extras_exporttemplate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE extras_exporttemplate_id_seq OWNED BY extras_exporttemplate.id; + + +-- +-- Name: extras_graph; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE extras_graph ( + id integer NOT NULL, + type smallint NOT NULL, + source character varying(500) NOT NULL, + link character varying(200) NOT NULL, + name character varying(100) NOT NULL, + weight smallint NOT NULL, + CONSTRAINT extras_graph_type_check CHECK ((type >= 0)), + CONSTRAINT extras_graph_weight_check CHECK ((weight >= 0)) +); + + +ALTER TABLE public.extras_graph OWNER TO django; + +-- +-- Name: extras_graph_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE extras_graph_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.extras_graph_id_seq OWNER TO django; + +-- +-- Name: extras_graph_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE extras_graph_id_seq OWNED BY extras_graph.id; + + +-- +-- Name: inet; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE inet ( + id integer NOT NULL, + field pg_catalog.inet NOT NULL +); + + +ALTER TABLE public.inet OWNER TO django; + +-- +-- Name: inet_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE inet_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.inet_id_seq OWNER TO django; + +-- +-- Name: inet_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE inet_id_seq OWNED BY inet.id; + + +-- +-- Name: ipam_aggregate; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_aggregate ( + id integer NOT NULL, + family smallint NOT NULL, + prefix pg_catalog.cidr NOT NULL, + rir_id integer NOT NULL, + date_added date, + description character varying(100) NOT NULL, + CONSTRAINT ipam_aggregate_family_check CHECK ((family >= 0)) +); + + +ALTER TABLE public.ipam_aggregate OWNER TO django; + +-- +-- Name: ipam_aggregate_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_aggregate_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_aggregate_id_seq OWNER TO django; + +-- +-- Name: ipam_aggregate_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_aggregate_id_seq OWNED BY ipam_aggregate.id; + + +-- +-- Name: ipam_ipaddress; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_ipaddress ( + id integer NOT NULL, + family smallint NOT NULL, + address pg_catalog.inet NOT NULL, + vrf_id integer, + interface_id integer, + nat_inside_id integer, + description character varying(100) NOT NULL, + CONSTRAINT ipam_ipaddress_family_check CHECK ((family >= 0)) +); + + +ALTER TABLE public.ipam_ipaddress OWNER TO django; + +-- +-- Name: ipam_ipaddress_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_ipaddress_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_ipaddress_id_seq OWNER TO django; + +-- +-- Name: ipam_ipaddress_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_ipaddress_id_seq OWNED BY ipam_ipaddress.id; + + +-- +-- Name: ipam_prefix; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_prefix ( + id integer NOT NULL, + family smallint NOT NULL, + prefix pg_catalog.cidr NOT NULL, + vrf_id integer, + description character varying(100) NOT NULL, + site_id integer, + vlan_id integer, + status_id integer NOT NULL, + role_id integer, + CONSTRAINT ipam_prefix_family_check CHECK ((family >= 0)) +); + + +ALTER TABLE public.ipam_prefix OWNER TO django; + +-- +-- Name: ipam_prefix_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_prefix_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_prefix_id_seq OWNER TO django; + +-- +-- Name: ipam_prefix_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_prefix_id_seq OWNED BY ipam_prefix.id; + + +-- +-- Name: ipam_rir; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_rir ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL +); + + +ALTER TABLE public.ipam_rir OWNER TO django; + +-- +-- Name: ipam_rir_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_rir_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_rir_id_seq OWNER TO django; + +-- +-- Name: ipam_rir_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_rir_id_seq OWNED BY ipam_rir.id; + + +-- +-- Name: ipam_role; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_role ( + id integer NOT NULL, + name character varying(50) NOT NULL, + weight smallint NOT NULL, + slug character varying(50) NOT NULL, + CONSTRAINT ipam_role_weight_check CHECK ((weight >= 0)) +); + + +ALTER TABLE public.ipam_role OWNER TO django; + +-- +-- Name: ipam_role_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_role_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_role_id_seq OWNER TO django; + +-- +-- Name: ipam_role_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_role_id_seq OWNED BY ipam_role.id; + + +-- +-- Name: ipam_status; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_status ( + id integer NOT NULL, + name character varying(50) NOT NULL, + weight smallint NOT NULL, + bootstrap_class smallint NOT NULL, + slug character varying(50) NOT NULL, + CONSTRAINT ipam_status_bootstrap_class_check CHECK ((bootstrap_class >= 0)), + CONSTRAINT ipam_status_weight_check CHECK ((weight >= 0)) +); + + +ALTER TABLE public.ipam_status OWNER TO django; + +-- +-- Name: ipam_status_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_status_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_status_id_seq OWNER TO django; + +-- +-- Name: ipam_status_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_status_id_seq OWNED BY ipam_status.id; + + +-- +-- Name: ipam_vlan; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_vlan ( + id integer NOT NULL, + vid smallint NOT NULL, + name character varying(30) NOT NULL, + site_id integer NOT NULL, + status_id integer NOT NULL, + role_id integer, + CONSTRAINT ipam_vlan_vid_check CHECK ((vid >= 0)) +); + + +ALTER TABLE public.ipam_vlan OWNER TO django; + +-- +-- Name: ipam_vlan_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_vlan_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_vlan_id_seq OWNER TO django; + +-- +-- Name: ipam_vlan_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_vlan_id_seq OWNED BY ipam_vlan.id; + + +-- +-- Name: ipam_vrf; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE ipam_vrf ( + id integer NOT NULL, + name character varying(50) NOT NULL, + description character varying(100) NOT NULL, + rd character varying(21) NOT NULL +); + + +ALTER TABLE public.ipam_vrf OWNER TO django; + +-- +-- Name: ipam_vrf_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE ipam_vrf_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.ipam_vrf_id_seq OWNER TO django; + +-- +-- Name: ipam_vrf_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE ipam_vrf_id_seq OWNED BY ipam_vrf.id; + + +-- +-- Name: mac; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE mac ( + id integer NOT NULL, + field macaddr +); + + +ALTER TABLE public.mac OWNER TO django; + +-- +-- Name: mac_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE mac_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.mac_id_seq OWNER TO django; + +-- +-- Name: mac_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE mac_id_seq OWNED BY mac.id; + + +-- +-- Name: nullcidr; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE nullcidr ( + id integer NOT NULL, + field pg_catalog.cidr +); + + +ALTER TABLE public.nullcidr OWNER TO django; + +-- +-- Name: nullcidr_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE nullcidr_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.nullcidr_id_seq OWNER TO django; + +-- +-- Name: nullcidr_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE nullcidr_id_seq OWNED BY nullcidr.id; + + +-- +-- Name: nullinet; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE nullinet ( + id integer NOT NULL, + field pg_catalog.inet +); + + +ALTER TABLE public.nullinet OWNER TO django; + +-- +-- Name: nullinet_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE nullinet_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.nullinet_id_seq OWNER TO django; + +-- +-- Name: nullinet_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE nullinet_id_seq OWNED BY nullinet.id; + + +-- +-- Name: secrets_secret; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE secrets_secret ( + id integer NOT NULL, + object_id integer NOT NULL, + name character varying(100) NOT NULL, + ciphertext bytea NOT NULL, + hash character varying(128) NOT NULL, + created timestamp with time zone NOT NULL, + last_modified timestamp with time zone NOT NULL, + content_type_id integer NOT NULL, + role_id integer NOT NULL, + CONSTRAINT secrets_secret_object_id_check CHECK ((object_id >= 0)) +); + + +ALTER TABLE public.secrets_secret OWNER TO django; + +-- +-- Name: secrets_secret_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE secrets_secret_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.secrets_secret_id_seq OWNER TO django; + +-- +-- Name: secrets_secret_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE secrets_secret_id_seq OWNED BY secrets_secret.id; + + +-- +-- Name: secrets_secretrole; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE secrets_secretrole ( + id integer NOT NULL, + name character varying(50) NOT NULL, + slug character varying(50) NOT NULL +); + + +ALTER TABLE public.secrets_secretrole OWNER TO django; + +-- +-- Name: secrets_secretrole_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE secrets_secretrole_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.secrets_secretrole_id_seq OWNER TO django; + +-- +-- Name: secrets_secretrole_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE secrets_secretrole_id_seq OWNED BY secrets_secretrole.id; + + +-- +-- Name: secrets_userkey; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE secrets_userkey ( + id integer NOT NULL, + public_key text NOT NULL, + user_id integer NOT NULL, + created timestamp with time zone NOT NULL, + master_key_cipher bytea, + last_modified timestamp with time zone NOT NULL +); + + +ALTER TABLE public.secrets_userkey OWNER TO django; + +-- +-- Name: secrets_userkey_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE secrets_userkey_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.secrets_userkey_id_seq OWNER TO django; + +-- +-- Name: secrets_userkey_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE secrets_userkey_id_seq OWNED BY secrets_userkey.id; + + +-- +-- Name: uniquecidr; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE uniquecidr ( + id integer NOT NULL, + field pg_catalog.cidr NOT NULL +); + + +ALTER TABLE public.uniquecidr OWNER TO django; + +-- +-- Name: uniquecidr_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE uniquecidr_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.uniquecidr_id_seq OWNER TO django; + +-- +-- Name: uniquecidr_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE uniquecidr_id_seq OWNED BY uniquecidr.id; + + +-- +-- Name: uniqueinet; Type: TABLE; Schema: public; Owner: django; Tablespace: +-- + +CREATE TABLE uniqueinet ( + id integer NOT NULL, + field pg_catalog.inet NOT NULL +); + + +ALTER TABLE public.uniqueinet OWNER TO django; + +-- +-- Name: uniqueinet_id_seq; Type: SEQUENCE; Schema: public; Owner: django +-- + +CREATE SEQUENCE uniqueinet_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1; + + +ALTER TABLE public.uniqueinet_id_seq OWNER TO django; + +-- +-- Name: uniqueinet_id_seq; Type: SEQUENCE OWNED BY; Schema: public; Owner: django +-- + +ALTER SEQUENCE uniqueinet_id_seq OWNED BY uniqueinet.id; + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_group ALTER COLUMN id SET DEFAULT nextval('auth_group_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_group_permissions ALTER COLUMN id SET DEFAULT nextval('auth_group_permissions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_permission ALTER COLUMN id SET DEFAULT nextval('auth_permission_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user ALTER COLUMN id SET DEFAULT nextval('auth_user_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user_groups ALTER COLUMN id SET DEFAULT nextval('auth_user_groups_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user_user_permissions ALTER COLUMN id SET DEFAULT nextval('auth_user_user_permissions_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY cidr ALTER COLUMN id SET DEFAULT nextval('cidr_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_circuit ALTER COLUMN id SET DEFAULT nextval('circuits_circuit_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_circuittype ALTER COLUMN id SET DEFAULT nextval('circuits_circuittype_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_provider ALTER COLUMN id SET DEFAULT nextval('circuits_provider_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY corsheaders_corsmodel ALTER COLUMN id SET DEFAULT nextval('corsheaders_corsmodel_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleport ALTER COLUMN id SET DEFAULT nextval('dcim_consoleport_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleporttemplate ALTER COLUMN id SET DEFAULT nextval('dcim_consoleporttemplate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleserverport ALTER COLUMN id SET DEFAULT nextval('dcim_consoleserverport_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleserverporttemplate ALTER COLUMN id SET DEFAULT nextval('dcim_consoleserverporttemplate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_device ALTER COLUMN id SET DEFAULT nextval('dcim_device_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_devicerole ALTER COLUMN id SET DEFAULT nextval('dcim_devicerole_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_devicetype ALTER COLUMN id SET DEFAULT nextval('dcim_devicetype_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interface ALTER COLUMN id SET DEFAULT nextval('dcim_interface_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interfaceconnection ALTER COLUMN id SET DEFAULT nextval('dcim_interfaceconnection_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interfacetemplate ALTER COLUMN id SET DEFAULT nextval('dcim_interfacetemplate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_manufacturer ALTER COLUMN id SET DEFAULT nextval('dcim_manufacturer_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_module ALTER COLUMN id SET DEFAULT nextval('dcim_module_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_platform ALTER COLUMN id SET DEFAULT nextval('dcim_platform_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_poweroutlet ALTER COLUMN id SET DEFAULT nextval('dcim_poweroutlet_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_poweroutlettemplate ALTER COLUMN id SET DEFAULT nextval('dcim_poweroutlettemplate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_powerport ALTER COLUMN id SET DEFAULT nextval('dcim_powerport_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_powerporttemplate ALTER COLUMN id SET DEFAULT nextval('dcim_powerporttemplate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_rack ALTER COLUMN id SET DEFAULT nextval('dcim_rack_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_rackgroup ALTER COLUMN id SET DEFAULT nextval('dcim_rackgroup_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_site ALTER COLUMN id SET DEFAULT nextval('dcim_site_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY django_admin_log ALTER COLUMN id SET DEFAULT nextval('django_admin_log_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY django_content_type ALTER COLUMN id SET DEFAULT nextval('django_content_type_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY django_migrations ALTER COLUMN id SET DEFAULT nextval('django_migrations_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY extras_exporttemplate ALTER COLUMN id SET DEFAULT nextval('extras_exporttemplate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY extras_graph ALTER COLUMN id SET DEFAULT nextval('extras_graph_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY inet ALTER COLUMN id SET DEFAULT nextval('inet_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_aggregate ALTER COLUMN id SET DEFAULT nextval('ipam_aggregate_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_ipaddress ALTER COLUMN id SET DEFAULT nextval('ipam_ipaddress_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_prefix ALTER COLUMN id SET DEFAULT nextval('ipam_prefix_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_rir ALTER COLUMN id SET DEFAULT nextval('ipam_rir_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_role ALTER COLUMN id SET DEFAULT nextval('ipam_role_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_status ALTER COLUMN id SET DEFAULT nextval('ipam_status_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_vlan ALTER COLUMN id SET DEFAULT nextval('ipam_vlan_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_vrf ALTER COLUMN id SET DEFAULT nextval('ipam_vrf_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY mac ALTER COLUMN id SET DEFAULT nextval('mac_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY nullcidr ALTER COLUMN id SET DEFAULT nextval('nullcidr_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY nullinet ALTER COLUMN id SET DEFAULT nextval('nullinet_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY secrets_secret ALTER COLUMN id SET DEFAULT nextval('secrets_secret_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY secrets_secretrole ALTER COLUMN id SET DEFAULT nextval('secrets_secretrole_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY secrets_userkey ALTER COLUMN id SET DEFAULT nextval('secrets_userkey_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY uniquecidr ALTER COLUMN id SET DEFAULT nextval('uniquecidr_id_seq'::regclass); + + +-- +-- Name: id; Type: DEFAULT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY uniqueinet ALTER COLUMN id SET DEFAULT nextval('uniqueinet_id_seq'::regclass); + + +-- +-- Name: auth_group_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_group + ADD CONSTRAINT auth_group_name_key UNIQUE (name); + + +-- +-- Name: auth_group_permissions_group_id_permission_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_group_permissions + ADD CONSTRAINT auth_group_permissions_group_id_permission_id_key UNIQUE (group_id, permission_id); + + +-- +-- Name: auth_group_permissions_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_group_permissions + ADD CONSTRAINT auth_group_permissions_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_group_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_group + ADD CONSTRAINT auth_group_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_permission_content_type_id_codename_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_permission + ADD CONSTRAINT auth_permission_content_type_id_codename_key UNIQUE (content_type_id, codename); + + +-- +-- Name: auth_permission_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_permission + ADD CONSTRAINT auth_permission_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_user_groups_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_user_groups + ADD CONSTRAINT auth_user_groups_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_user_groups_user_id_group_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_user_groups + ADD CONSTRAINT auth_user_groups_user_id_group_id_key UNIQUE (user_id, group_id); + + +-- +-- Name: auth_user_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_user + ADD CONSTRAINT auth_user_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_user_user_permissions_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_user_user_permissions + ADD CONSTRAINT auth_user_user_permissions_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_user_user_permissions_user_id_permission_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_user_user_permissions + ADD CONSTRAINT auth_user_user_permissions_user_id_permission_id_key UNIQUE (user_id, permission_id); + + +-- +-- Name: auth_user_username_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY auth_user + ADD CONSTRAINT auth_user_username_key UNIQUE (username); + + +-- +-- Name: cidr_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY cidr + ADD CONSTRAINT cidr_pkey PRIMARY KEY (id); + + +-- +-- Name: circuits_circuit_interface_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_circuit_interface_id_key UNIQUE (interface_id); + + +-- +-- Name: circuits_circuit_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_circuit_pkey PRIMARY KEY (id); + + +-- +-- Name: circuits_circuit_provider_id_4eab740723ebc621_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_circuit_provider_id_4eab740723ebc621_uniq UNIQUE (provider_id, cid); + + +-- +-- Name: circuits_circuittype_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_circuittype + ADD CONSTRAINT circuits_circuittype_name_key UNIQUE (name); + + +-- +-- Name: circuits_circuittype_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_circuittype + ADD CONSTRAINT circuits_circuittype_pkey PRIMARY KEY (id); + + +-- +-- Name: circuits_circuittype_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_circuittype + ADD CONSTRAINT circuits_circuittype_slug_key UNIQUE (slug); + + +-- +-- Name: circuits_provider_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_provider + ADD CONSTRAINT circuits_provider_name_key UNIQUE (name); + + +-- +-- Name: circuits_provider_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_provider + ADD CONSTRAINT circuits_provider_pkey PRIMARY KEY (id); + + +-- +-- Name: circuits_provider_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY circuits_provider + ADD CONSTRAINT circuits_provider_slug_key UNIQUE (slug); + + +-- +-- Name: corsheaders_corsmodel_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY corsheaders_corsmodel + ADD CONSTRAINT corsheaders_corsmodel_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_consoleport_cs_port_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleport + ADD CONSTRAINT dcim_consoleport_cs_port_id_key UNIQUE (cs_port_id); + + +-- +-- Name: dcim_consoleport_device_id_2bfdd4b8ce9af21e_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleport + ADD CONSTRAINT dcim_consoleport_device_id_2bfdd4b8ce9af21e_uniq UNIQUE (device_id, name); + + +-- +-- Name: dcim_consoleport_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleport + ADD CONSTRAINT dcim_consoleport_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_consoleporttemplate_device_type_id_4181f4f26d97545e_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleporttemplate + ADD CONSTRAINT dcim_consoleporttemplate_device_type_id_4181f4f26d97545e_uniq UNIQUE (device_type_id, name); + + +-- +-- Name: dcim_consoleporttemplate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleporttemplate + ADD CONSTRAINT dcim_consoleporttemplate_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_consoleserverport_device_id_7736709af378c53f_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleserverport + ADD CONSTRAINT dcim_consoleserverport_device_id_7736709af378c53f_uniq UNIQUE (device_id, name); + + +-- +-- Name: dcim_consoleserverport_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleserverport + ADD CONSTRAINT dcim_consoleserverport_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_consoleserverporttempl_device_type_id_edd19c09550c93f_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleserverporttemplate + ADD CONSTRAINT dcim_consoleserverporttempl_device_type_id_edd19c09550c93f_uniq UNIQUE (device_type_id, name); + + +-- +-- Name: dcim_consoleserverporttemplate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_consoleserverporttemplate + ADD CONSTRAINT dcim_consoleserverporttemplate_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_device_name_203dd9298ce638c1_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_name_203dd9298ce638c1_uniq UNIQUE (name); + + +-- +-- Name: dcim_device_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_device_primary_ip_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_primary_ip_id_key UNIQUE (primary_ip_id); + + +-- +-- Name: dcim_device_rack_id_51ba816b607befb4_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_rack_id_51ba816b607befb4_uniq UNIQUE (rack_id, "position", face); + + +-- +-- Name: dcim_devicerole_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_devicerole + ADD CONSTRAINT dcim_devicerole_name_key UNIQUE (name); + + +-- +-- Name: dcim_devicerole_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_devicerole + ADD CONSTRAINT dcim_devicerole_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_devicerole_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_devicerole + ADD CONSTRAINT dcim_devicerole_slug_key UNIQUE (slug); + + +-- +-- Name: dcim_devicetype_manufacturer_id_1261ef49562adaa4_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_devicetype + ADD CONSTRAINT dcim_devicetype_manufacturer_id_1261ef49562adaa4_uniq UNIQUE (manufacturer_id, slug); + + +-- +-- Name: dcim_devicetype_manufacturer_id_1cfa2f3e364bcae3_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_devicetype + ADD CONSTRAINT dcim_devicetype_manufacturer_id_1cfa2f3e364bcae3_uniq UNIQUE (manufacturer_id, model); + + +-- +-- Name: dcim_devicetype_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_devicetype + ADD CONSTRAINT dcim_devicetype_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_interface_device_id_1a96eafe3cd9e3df_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interface + ADD CONSTRAINT dcim_interface_device_id_1a96eafe3cd9e3df_uniq UNIQUE (device_id, name); + + +-- +-- Name: dcim_interface_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interface + ADD CONSTRAINT dcim_interface_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_interfaceconnection_interface_a_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interfaceconnection + ADD CONSTRAINT dcim_interfaceconnection_interface_a_id_key UNIQUE (interface_a_id); + + +-- +-- Name: dcim_interfaceconnection_interface_b_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interfaceconnection + ADD CONSTRAINT dcim_interfaceconnection_interface_b_id_key UNIQUE (interface_b_id); + + +-- +-- Name: dcim_interfaceconnection_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interfaceconnection + ADD CONSTRAINT dcim_interfaceconnection_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_interfacetemplate_device_type_id_7a05c4e376f93953_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interfacetemplate + ADD CONSTRAINT dcim_interfacetemplate_device_type_id_7a05c4e376f93953_uniq UNIQUE (device_type_id, name); + + +-- +-- Name: dcim_interfacetemplate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_interfacetemplate + ADD CONSTRAINT dcim_interfacetemplate_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_manufacturer_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_manufacturer + ADD CONSTRAINT dcim_manufacturer_name_key UNIQUE (name); + + +-- +-- Name: dcim_manufacturer_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_manufacturer + ADD CONSTRAINT dcim_manufacturer_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_manufacturer_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_manufacturer + ADD CONSTRAINT dcim_manufacturer_slug_key UNIQUE (slug); + + +-- +-- Name: dcim_module_device_id_44410c90b98b7fd5_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_module + ADD CONSTRAINT dcim_module_device_id_44410c90b98b7fd5_uniq UNIQUE (device_id, name); + + +-- +-- Name: dcim_module_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_module + ADD CONSTRAINT dcim_module_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_platform_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_platform + ADD CONSTRAINT dcim_platform_name_key UNIQUE (name); + + +-- +-- Name: dcim_platform_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_platform + ADD CONSTRAINT dcim_platform_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_platform_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_platform + ADD CONSTRAINT dcim_platform_slug_key UNIQUE (slug); + + +-- +-- Name: dcim_poweroutlet_device_id_7c22b6bb01a5ff2c_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_poweroutlet + ADD CONSTRAINT dcim_poweroutlet_device_id_7c22b6bb01a5ff2c_uniq UNIQUE (device_id, name); + + +-- +-- Name: dcim_poweroutlet_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_poweroutlet + ADD CONSTRAINT dcim_poweroutlet_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_poweroutlettemplate_device_type_id_6e69f9502b62feb8_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_poweroutlettemplate + ADD CONSTRAINT dcim_poweroutlettemplate_device_type_id_6e69f9502b62feb8_uniq UNIQUE (device_type_id, name); + + +-- +-- Name: dcim_poweroutlettemplate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_poweroutlettemplate + ADD CONSTRAINT dcim_poweroutlettemplate_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_powerport_device_id_75960a10f268db28_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_powerport + ADD CONSTRAINT dcim_powerport_device_id_75960a10f268db28_uniq UNIQUE (device_id, name); + + +-- +-- Name: dcim_powerport_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_powerport + ADD CONSTRAINT dcim_powerport_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_powerport_power_outlet_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_powerport + ADD CONSTRAINT dcim_powerport_power_outlet_id_key UNIQUE (power_outlet_id); + + +-- +-- Name: dcim_powerporttemplate_device_type_id_13286ca135e2f6c0_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_powerporttemplate + ADD CONSTRAINT dcim_powerporttemplate_device_type_id_13286ca135e2f6c0_uniq UNIQUE (device_type_id, name); + + +-- +-- Name: dcim_powerporttemplate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_powerporttemplate + ADD CONSTRAINT dcim_powerporttemplate_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_rack_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_rack + ADD CONSTRAINT dcim_rack_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_rack_site_id_30be92c1bfc1d387_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_rack + ADD CONSTRAINT dcim_rack_site_id_30be92c1bfc1d387_uniq UNIQUE (site_id, name); + + +-- +-- Name: dcim_rack_site_id_69909272a1e4c508_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_rack + ADD CONSTRAINT dcim_rack_site_id_69909272a1e4c508_uniq UNIQUE (site_id, facility_id); + + +-- +-- Name: dcim_rackgroup_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_rackgroup + ADD CONSTRAINT dcim_rackgroup_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_rackgroup_site_id_7fbfd118_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_rackgroup + ADD CONSTRAINT dcim_rackgroup_site_id_7fbfd118_uniq UNIQUE (site_id, slug); + + +-- +-- Name: dcim_rackgroup_site_id_c9bd921f_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_rackgroup + ADD CONSTRAINT dcim_rackgroup_site_id_c9bd921f_uniq UNIQUE (site_id, name); + + +-- +-- Name: dcim_site_name_78bc7a96590ccbd0_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_site + ADD CONSTRAINT dcim_site_name_78bc7a96590ccbd0_uniq UNIQUE (name); + + +-- +-- Name: dcim_site_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_site + ADD CONSTRAINT dcim_site_pkey PRIMARY KEY (id); + + +-- +-- Name: dcim_site_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY dcim_site + ADD CONSTRAINT dcim_site_slug_key UNIQUE (slug); + + +-- +-- Name: django_admin_log_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY django_admin_log + ADD CONSTRAINT django_admin_log_pkey PRIMARY KEY (id); + + +-- +-- Name: django_content_type_app_label_45f3b1d93ec8c61c_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY django_content_type + ADD CONSTRAINT django_content_type_app_label_45f3b1d93ec8c61c_uniq UNIQUE (app_label, model); + + +-- +-- Name: django_content_type_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY django_content_type + ADD CONSTRAINT django_content_type_pkey PRIMARY KEY (id); + + +-- +-- Name: django_migrations_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY django_migrations + ADD CONSTRAINT django_migrations_pkey PRIMARY KEY (id); + + +-- +-- Name: django_session_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY django_session + ADD CONSTRAINT django_session_pkey PRIMARY KEY (session_key); + + +-- +-- Name: extras_exporttemplate_content_type_id_7c9266ee8ac6d527_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY extras_exporttemplate + ADD CONSTRAINT extras_exporttemplate_content_type_id_7c9266ee8ac6d527_uniq UNIQUE (content_type_id, name); + + +-- +-- Name: extras_exporttemplate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY extras_exporttemplate + ADD CONSTRAINT extras_exporttemplate_pkey PRIMARY KEY (id); + + +-- +-- Name: extras_graph_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY extras_graph + ADD CONSTRAINT extras_graph_pkey PRIMARY KEY (id); + + +-- +-- Name: inet_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY inet + ADD CONSTRAINT inet_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_aggregate_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_aggregate + ADD CONSTRAINT ipam_aggregate_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_ipaddress_nat_inside_id_54e134739a4fce35_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_ipaddress + ADD CONSTRAINT ipam_ipaddress_nat_inside_id_54e134739a4fce35_uniq UNIQUE (nat_inside_id); + + +-- +-- Name: ipam_ipaddress_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_ipaddress + ADD CONSTRAINT ipam_ipaddress_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_prefix_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_prefix + ADD CONSTRAINT ipam_prefix_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_rir_name_189e93024f01ec65_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_rir + ADD CONSTRAINT ipam_rir_name_189e93024f01ec65_uniq UNIQUE (name); + + +-- +-- Name: ipam_rir_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_rir + ADD CONSTRAINT ipam_rir_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_rir_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_rir + ADD CONSTRAINT ipam_rir_slug_key UNIQUE (slug); + + +-- +-- Name: ipam_role_name_1f2da3fe0d1ed5cf_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_role + ADD CONSTRAINT ipam_role_name_1f2da3fe0d1ed5cf_uniq UNIQUE (name); + + +-- +-- Name: ipam_role_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_role + ADD CONSTRAINT ipam_role_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_role_slug_b1b7426c7eb1a07_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_role + ADD CONSTRAINT ipam_role_slug_b1b7426c7eb1a07_uniq UNIQUE (slug); + + +-- +-- Name: ipam_status_name_70695c5e5c2b0c2b_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_status + ADD CONSTRAINT ipam_status_name_70695c5e5c2b0c2b_uniq UNIQUE (name); + + +-- +-- Name: ipam_status_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_status + ADD CONSTRAINT ipam_status_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_status_slug_a16a9e1e0e5e16d_uniq; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_status + ADD CONSTRAINT ipam_status_slug_a16a9e1e0e5e16d_uniq UNIQUE (slug); + + +-- +-- Name: ipam_vlan_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_vlan + ADD CONSTRAINT ipam_vlan_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_vrf_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_vrf + ADD CONSTRAINT ipam_vrf_pkey PRIMARY KEY (id); + + +-- +-- Name: ipam_vrf_rd_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY ipam_vrf + ADD CONSTRAINT ipam_vrf_rd_key UNIQUE (rd); + + +-- +-- Name: mac_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY mac + ADD CONSTRAINT mac_pkey PRIMARY KEY (id); + + +-- +-- Name: nullcidr_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY nullcidr + ADD CONSTRAINT nullcidr_pkey PRIMARY KEY (id); + + +-- +-- Name: nullinet_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY nullinet + ADD CONSTRAINT nullinet_pkey PRIMARY KEY (id); + + +-- +-- Name: secrets_secret_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY secrets_secret + ADD CONSTRAINT secrets_secret_pkey PRIMARY KEY (id); + + +-- +-- Name: secrets_secretrole_name_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY secrets_secretrole + ADD CONSTRAINT secrets_secretrole_name_key UNIQUE (name); + + +-- +-- Name: secrets_secretrole_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY secrets_secretrole + ADD CONSTRAINT secrets_secretrole_pkey PRIMARY KEY (id); + + +-- +-- Name: secrets_secretrole_slug_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY secrets_secretrole + ADD CONSTRAINT secrets_secretrole_slug_key UNIQUE (slug); + + +-- +-- Name: secrets_userkey_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY secrets_userkey + ADD CONSTRAINT secrets_userkey_pkey PRIMARY KEY (id); + + +-- +-- Name: secrets_userkey_user_id_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY secrets_userkey + ADD CONSTRAINT secrets_userkey_user_id_key UNIQUE (user_id); + + +-- +-- Name: uniquecidr_field_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY uniquecidr + ADD CONSTRAINT uniquecidr_field_key UNIQUE (field); + + +-- +-- Name: uniquecidr_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY uniquecidr + ADD CONSTRAINT uniquecidr_pkey PRIMARY KEY (id); + + +-- +-- Name: uniqueinet_field_key; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY uniqueinet + ADD CONSTRAINT uniqueinet_field_key UNIQUE (field); + + +-- +-- Name: uniqueinet_pkey; Type: CONSTRAINT; Schema: public; Owner: django; Tablespace: +-- + +ALTER TABLE ONLY uniqueinet + ADD CONSTRAINT uniqueinet_pkey PRIMARY KEY (id); + + +-- +-- Name: auth_group_name_253ae2a6331666e8_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_group_name_253ae2a6331666e8_like ON auth_group USING btree (name varchar_pattern_ops); + + +-- +-- Name: auth_group_permissions_0e939a4f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_group_permissions_0e939a4f ON auth_group_permissions USING btree (group_id); + + +-- +-- Name: auth_group_permissions_8373b171; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_group_permissions_8373b171 ON auth_group_permissions USING btree (permission_id); + + +-- +-- Name: auth_permission_417f1b1c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_permission_417f1b1c ON auth_permission USING btree (content_type_id); + + +-- +-- Name: auth_user_groups_0e939a4f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_user_groups_0e939a4f ON auth_user_groups USING btree (group_id); + + +-- +-- Name: auth_user_groups_e8701ad4; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_user_groups_e8701ad4 ON auth_user_groups USING btree (user_id); + + +-- +-- Name: auth_user_user_permissions_8373b171; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_user_user_permissions_8373b171 ON auth_user_user_permissions USING btree (permission_id); + + +-- +-- Name: auth_user_user_permissions_e8701ad4; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_user_user_permissions_e8701ad4 ON auth_user_user_permissions USING btree (user_id); + + +-- +-- Name: auth_user_username_51b3b110094b8aae_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX auth_user_username_51b3b110094b8aae_like ON auth_user USING btree (username varchar_pattern_ops); + + +-- +-- Name: circuits_circuit_32ca2ddc; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_circuit_32ca2ddc ON circuits_circuit USING btree (provider_id); + + +-- +-- Name: circuits_circuit_9365d6e7; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_circuit_9365d6e7 ON circuits_circuit USING btree (site_id); + + +-- +-- Name: circuits_circuit_94757cae; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_circuit_94757cae ON circuits_circuit USING btree (type_id); + + +-- +-- Name: circuits_circuittype_name_1c2ade2dc0696954_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_circuittype_name_1c2ade2dc0696954_like ON circuits_circuittype USING btree (name varchar_pattern_ops); + + +-- +-- Name: circuits_circuittype_slug_476ab74403291bbc_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_circuittype_slug_476ab74403291bbc_like ON circuits_circuittype USING btree (slug varchar_pattern_ops); + + +-- +-- Name: circuits_provider_name_6edbf97e6646bc6d_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_provider_name_6edbf97e6646bc6d_like ON circuits_provider USING btree (name varchar_pattern_ops); + + +-- +-- Name: circuits_provider_slug_14c10aece416912b_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX circuits_provider_slug_14c10aece416912b_like ON circuits_provider USING btree (slug varchar_pattern_ops); + + +-- +-- Name: dcim_consoleport_9379346c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_consoleport_9379346c ON dcim_consoleport USING btree (device_id); + + +-- +-- Name: dcim_consoleporttemplate_bddcf45f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_consoleporttemplate_bddcf45f ON dcim_consoleporttemplate USING btree (device_type_id); + + +-- +-- Name: dcim_consoleserverport_9379346c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_consoleserverport_9379346c ON dcim_consoleserverport USING btree (device_id); + + +-- +-- Name: dcim_consoleserverporttemplate_bddcf45f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_consoleserverporttemplate_bddcf45f ON dcim_consoleserverporttemplate USING btree (device_type_id); + + +-- +-- Name: dcim_device_136ca3fc; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_device_136ca3fc ON dcim_device USING btree (device_role_id); + + +-- +-- Name: dcim_device_21556361; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_device_21556361 ON dcim_device USING btree (rack_id); + + +-- +-- Name: dcim_device_bddcf45f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_device_bddcf45f ON dcim_device USING btree (device_type_id); + + +-- +-- Name: dcim_device_cb857215; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_device_cb857215 ON dcim_device USING btree (platform_id); + + +-- +-- Name: dcim_devicerole_name_2ba786129816183_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_devicerole_name_2ba786129816183_like ON dcim_devicerole USING btree (name varchar_pattern_ops); + + +-- +-- Name: dcim_devicetype_2dbcba41; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_devicetype_2dbcba41 ON dcim_devicetype USING btree (slug); + + +-- +-- Name: dcim_devicetype_4d136c4a; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_devicetype_4d136c4a ON dcim_devicetype USING btree (manufacturer_id); + + +-- +-- Name: dcim_interface_9379346c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_interface_9379346c ON dcim_interface USING btree (device_id); + + +-- +-- Name: dcim_interfacetemplate_bddcf45f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_interfacetemplate_bddcf45f ON dcim_interfacetemplate USING btree (device_type_id); + + +-- +-- Name: dcim_manufacturer_name_d0e87afc92d84ee_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_manufacturer_name_d0e87afc92d84ee_like ON dcim_manufacturer USING btree (name varchar_pattern_ops); + + +-- +-- Name: dcim_module_9379346c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_module_9379346c ON dcim_module USING btree (device_id); + + +-- +-- Name: dcim_platform_name_79dfde6abeff3d4_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_platform_name_79dfde6abeff3d4_like ON dcim_platform USING btree (name varchar_pattern_ops); + + +-- +-- Name: dcim_platform_slug_7c74a6b8ac58979c_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_platform_slug_7c74a6b8ac58979c_like ON dcim_platform USING btree (slug varchar_pattern_ops); + + +-- +-- Name: dcim_poweroutlet_9379346c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_poweroutlet_9379346c ON dcim_poweroutlet USING btree (device_id); + + +-- +-- Name: dcim_poweroutlettemplate_bddcf45f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_poweroutlettemplate_bddcf45f ON dcim_poweroutlettemplate USING btree (device_type_id); + + +-- +-- Name: dcim_powerport_9379346c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_powerport_9379346c ON dcim_powerport USING btree (device_id); + + +-- +-- Name: dcim_powerporttemplate_bddcf45f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_powerporttemplate_bddcf45f ON dcim_powerporttemplate USING btree (device_type_id); + + +-- +-- Name: dcim_rack_0e939a4f; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_rack_0e939a4f ON dcim_rack USING btree (group_id); + + +-- +-- Name: dcim_rack_9365d6e7; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_rack_9365d6e7 ON dcim_rack USING btree (site_id); + + +-- +-- Name: dcim_rackgroup_2dbcba41; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_rackgroup_2dbcba41 ON dcim_rackgroup USING btree (slug); + + +-- +-- Name: dcim_rackgroup_9365d6e7; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_rackgroup_9365d6e7 ON dcim_rackgroup USING btree (site_id); + + +-- +-- Name: dcim_rackgroup_slug_3f4582a7_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_rackgroup_slug_3f4582a7_like ON dcim_rackgroup USING btree (slug varchar_pattern_ops); + + +-- +-- Name: dcim_site_slug_7e27fbff5c4239c8_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX dcim_site_slug_7e27fbff5c4239c8_like ON dcim_site USING btree (slug varchar_pattern_ops); + + +-- +-- Name: django_admin_log_417f1b1c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX django_admin_log_417f1b1c ON django_admin_log USING btree (content_type_id); + + +-- +-- Name: django_admin_log_e8701ad4; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX django_admin_log_e8701ad4 ON django_admin_log USING btree (user_id); + + +-- +-- Name: django_session_de54fa62; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX django_session_de54fa62 ON django_session USING btree (expire_date); + + +-- +-- Name: django_session_session_key_461cfeaa630ca218_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX django_session_session_key_461cfeaa630ca218_like ON django_session USING btree (session_key varchar_pattern_ops); + + +-- +-- Name: extras_exporttemplate_417f1b1c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX extras_exporttemplate_417f1b1c ON extras_exporttemplate USING btree (content_type_id); + + +-- +-- Name: ipam_aggregate_rir_id_6b95f7cbf861b265_uniq; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_aggregate_rir_id_6b95f7cbf861b265_uniq ON ipam_aggregate USING btree (rir_id); + + +-- +-- Name: ipam_ipaddress_0db30079; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_ipaddress_0db30079 ON ipam_ipaddress USING btree (vrf_id); + + +-- +-- Name: ipam_ipaddress_455280ca; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_ipaddress_455280ca ON ipam_ipaddress USING btree (nat_inside_id); + + +-- +-- Name: ipam_ipaddress_991706b3; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_ipaddress_991706b3 ON ipam_ipaddress USING btree (interface_id); + + +-- +-- Name: ipam_prefix_0db30079; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_prefix_0db30079 ON ipam_prefix USING btree (vrf_id); + + +-- +-- Name: ipam_prefix_84566833; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_prefix_84566833 ON ipam_prefix USING btree (role_id); + + +-- +-- Name: ipam_prefix_9365d6e7; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_prefix_9365d6e7 ON ipam_prefix USING btree (site_id); + + +-- +-- Name: ipam_prefix_cd1dc8b7; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_prefix_cd1dc8b7 ON ipam_prefix USING btree (vlan_id); + + +-- +-- Name: ipam_prefix_dc91ed4b; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_prefix_dc91ed4b ON ipam_prefix USING btree (status_id); + + +-- +-- Name: ipam_rir_slug_416a41a245986cd_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_rir_slug_416a41a245986cd_like ON ipam_rir USING btree (slug varchar_pattern_ops); + + +-- +-- Name: ipam_role_2dbcba41; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_role_2dbcba41 ON ipam_role USING btree (slug); + + +-- +-- Name: ipam_status_2dbcba41; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_status_2dbcba41 ON ipam_status USING btree (slug); + + +-- +-- Name: ipam_vlan_84566833; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_vlan_84566833 ON ipam_vlan USING btree (role_id); + + +-- +-- Name: ipam_vlan_9365d6e7; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_vlan_9365d6e7 ON ipam_vlan USING btree (site_id); + + +-- +-- Name: ipam_vlan_dc91ed4b; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX ipam_vlan_dc91ed4b ON ipam_vlan USING btree (status_id); + + +-- +-- Name: secrets_secret_417f1b1c; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX secrets_secret_417f1b1c ON secrets_secret USING btree (content_type_id); + + +-- +-- Name: secrets_secret_84566833; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX secrets_secret_84566833 ON secrets_secret USING btree (role_id); + + +-- +-- Name: secrets_secretrole_name_7b6ee7a4_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX secrets_secretrole_name_7b6ee7a4_like ON secrets_secretrole USING btree (name varchar_pattern_ops); + + +-- +-- Name: secrets_secretrole_slug_a06c885e_like; Type: INDEX; Schema: public; Owner: django; Tablespace: +-- + +CREATE INDEX secrets_secretrole_slug_a06c885e_like ON secrets_secretrole USING btree (slug varchar_pattern_ops); + + +-- +-- Name: auth_content_type_id_508cf46651277a81_fk_django_content_type_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_permission + ADD CONSTRAINT auth_content_type_id_508cf46651277a81_fk_django_content_type_id FOREIGN KEY (content_type_id) REFERENCES django_content_type(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: auth_group_permissio_group_id_689710a9a73b7457_fk_auth_group_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_group_permissions + ADD CONSTRAINT auth_group_permissio_group_id_689710a9a73b7457_fk_auth_group_id FOREIGN KEY (group_id) REFERENCES auth_group(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: auth_group_permission_id_1f49ccbbdc69d2fc_fk_auth_permission_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_group_permissions + ADD CONSTRAINT auth_group_permission_id_1f49ccbbdc69d2fc_fk_auth_permission_id FOREIGN KEY (permission_id) REFERENCES auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: auth_user__permission_id_384b62483d7071f0_fk_auth_permission_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user_user_permissions + ADD CONSTRAINT auth_user__permission_id_384b62483d7071f0_fk_auth_permission_id FOREIGN KEY (permission_id) REFERENCES auth_permission(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: auth_user_groups_group_id_33ac548dcf5f8e37_fk_auth_group_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user_groups + ADD CONSTRAINT auth_user_groups_group_id_33ac548dcf5f8e37_fk_auth_group_id FOREIGN KEY (group_id) REFERENCES auth_group(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: auth_user_groups_user_id_4b5ed4ffdb8fd9b0_fk_auth_user_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user_groups + ADD CONSTRAINT auth_user_groups_user_id_4b5ed4ffdb8fd9b0_fk_auth_user_id FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: auth_user_user_permiss_user_id_7f0938558328534a_fk_auth_user_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY auth_user_user_permissions + ADD CONSTRAINT auth_user_user_permiss_user_id_7f0938558328534a_fk_auth_user_id FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: circuits_c_provider_id_167247d72362b097_fk_circuits_provider_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_c_provider_id_167247d72362b097_fk_circuits_provider_id FOREIGN KEY (provider_id) REFERENCES circuits_provider(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: circuits_ci_type_id_1d69462c5f0198ee_fk_circuits_circuittype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_ci_type_id_1d69462c5f0198ee_fk_circuits_circuittype_id FOREIGN KEY (type_id) REFERENCES circuits_circuittype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: circuits_circ_interface_id_a7a235094605e66_fk_dcim_interface_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_circ_interface_id_a7a235094605e66_fk_dcim_interface_id FOREIGN KEY (interface_id) REFERENCES dcim_interface(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: circuits_circuit_site_id_1fda25e8f4b8a5d_fk_dcim_site_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY circuits_circuit + ADD CONSTRAINT circuits_circuit_site_id_1fda25e8f4b8a5d_fk_dcim_site_id FOREIGN KEY (site_id) REFERENCES dcim_site(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_co_cs_port_id_1f865a9aeca79c3_fk_dcim_consoleserverport_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleport + ADD CONSTRAINT dcim_co_cs_port_id_1f865a9aeca79c3_fk_dcim_consoleserverport_id FOREIGN KEY (cs_port_id) REFERENCES dcim_consoleserverport(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_cons_device_type_id_2b0cd8d64161d670_fk_dcim_devicetype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleporttemplate + ADD CONSTRAINT dcim_cons_device_type_id_2b0cd8d64161d670_fk_dcim_devicetype_id FOREIGN KEY (device_type_id) REFERENCES dcim_devicetype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_conso_device_type_id_9b0ca867cae19a1_fk_dcim_devicetype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleserverporttemplate + ADD CONSTRAINT dcim_conso_device_type_id_9b0ca867cae19a1_fk_dcim_devicetype_id FOREIGN KEY (device_type_id) REFERENCES dcim_devicetype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_consoleport_device_id_29b9f8e27e9d6770_fk_dcim_device_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleport + ADD CONSTRAINT dcim_consoleport_device_id_29b9f8e27e9d6770_fk_dcim_device_id FOREIGN KEY (device_id) REFERENCES dcim_device(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_consoleserver_device_id_511cd2919b14863f_fk_dcim_device_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_consoleserverport + ADD CONSTRAINT dcim_consoleserver_device_id_511cd2919b14863f_fk_dcim_device_id FOREIGN KEY (device_id) REFERENCES dcim_device(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_d_manufacturer_id_579c553080e9dedc_fk_dcim_manufacturer_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_devicetype + ADD CONSTRAINT dcim_d_manufacturer_id_579c553080e9dedc_fk_dcim_manufacturer_id FOREIGN KEY (manufacturer_id) REFERENCES dcim_manufacturer(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_devi_device_type_id_52445e10d85be955_fk_dcim_devicetype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_devi_device_type_id_52445e10d85be955_fk_dcim_devicetype_id FOREIGN KEY (device_type_id) REFERENCES dcim_devicetype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_devic_device_role_id_56eba740fa716a1_fk_dcim_devicerole_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_devic_device_role_id_56eba740fa716a1_fk_dcim_devicerole_id FOREIGN KEY (device_role_id) REFERENCES dcim_devicerole(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_device_platform_id_23623cd01a633f9a_fk_dcim_platform_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_platform_id_23623cd01a633f9a_fk_dcim_platform_id FOREIGN KEY (platform_id) REFERENCES dcim_platform(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_device_primary_ip_id_584ce7bd0806540b_fk_ipam_ipaddress_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_primary_ip_id_584ce7bd0806540b_fk_ipam_ipaddress_id FOREIGN KEY (primary_ip_id) REFERENCES ipam_ipaddress(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_device_rack_id_6e66edde5ed2479a_fk_dcim_rack_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_device + ADD CONSTRAINT dcim_device_rack_id_6e66edde5ed2479a_fk_dcim_rack_id FOREIGN KEY (rack_id) REFERENCES dcim_rack(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_inte_device_type_id_39b236aeb5adb9e5_fk_dcim_devicetype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interfacetemplate + ADD CONSTRAINT dcim_inte_device_type_id_39b236aeb5adb9e5_fk_dcim_devicetype_id FOREIGN KEY (device_type_id) REFERENCES dcim_devicetype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_inter_interface_a_id_4a90ee91ee670fa1_fk_dcim_interface_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interfaceconnection + ADD CONSTRAINT dcim_inter_interface_a_id_4a90ee91ee670fa1_fk_dcim_interface_id FOREIGN KEY (interface_a_id) REFERENCES dcim_interface(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_inter_interface_b_id_1e536e3d7fa00862_fk_dcim_interface_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interfaceconnection + ADD CONSTRAINT dcim_inter_interface_b_id_1e536e3d7fa00862_fk_dcim_interface_id FOREIGN KEY (interface_b_id) REFERENCES dcim_interface(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_interface_device_id_cebcbb2c2f43d21_fk_dcim_device_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_interface + ADD CONSTRAINT dcim_interface_device_id_cebcbb2c2f43d21_fk_dcim_device_id FOREIGN KEY (device_id) REFERENCES dcim_device(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_module_device_id_75c6e9c983691bed_fk_dcim_device_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_module + ADD CONSTRAINT dcim_module_device_id_75c6e9c983691bed_fk_dcim_device_id FOREIGN KEY (device_id) REFERENCES dcim_device(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_po_power_outlet_id_4099940c71613091_fk_dcim_poweroutlet_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_powerport + ADD CONSTRAINT dcim_po_power_outlet_id_4099940c71613091_fk_dcim_poweroutlet_id FOREIGN KEY (power_outlet_id) REFERENCES dcim_poweroutlet(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_powe_device_type_id_384e9ac366036152_fk_dcim_devicetype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_powerporttemplate + ADD CONSTRAINT dcim_powe_device_type_id_384e9ac366036152_fk_dcim_devicetype_id FOREIGN KEY (device_type_id) REFERENCES dcim_devicetype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_powe_device_type_id_7807d6dc359b9cd6_fk_dcim_devicetype_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_poweroutlettemplate + ADD CONSTRAINT dcim_powe_device_type_id_7807d6dc359b9cd6_fk_dcim_devicetype_id FOREIGN KEY (device_type_id) REFERENCES dcim_devicetype(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_poweroutlet_device_id_5e311222f8451092_fk_dcim_device_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_poweroutlet + ADD CONSTRAINT dcim_poweroutlet_device_id_5e311222f8451092_fk_dcim_device_id FOREIGN KEY (device_id) REFERENCES dcim_device(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_powerport_device_id_67713503b63c2a2a_fk_dcim_device_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_powerport + ADD CONSTRAINT dcim_powerport_device_id_67713503b63c2a2a_fk_dcim_device_id FOREIGN KEY (device_id) REFERENCES dcim_device(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_rack_group_id_44e90ea9_fk_dcim_rackgroup_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_rack + ADD CONSTRAINT dcim_rack_group_id_44e90ea9_fk_dcim_rackgroup_id FOREIGN KEY (group_id) REFERENCES dcim_rackgroup(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_rack_site_id_5d7ccc420afb55f5_fk_dcim_site_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_rack + ADD CONSTRAINT dcim_rack_site_id_5d7ccc420afb55f5_fk_dcim_site_id FOREIGN KEY (site_id) REFERENCES dcim_site(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: dcim_rackgroup_site_id_13520e89_fk_dcim_site_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY dcim_rackgroup + ADD CONSTRAINT dcim_rackgroup_site_id_13520e89_fk_dcim_site_id FOREIGN KEY (site_id) REFERENCES dcim_site(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: djan_content_type_id_697914295151027a_fk_django_content_type_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY django_admin_log + ADD CONSTRAINT djan_content_type_id_697914295151027a_fk_django_content_type_id FOREIGN KEY (content_type_id) REFERENCES django_content_type(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: django_admin_log_user_id_52fdd58701c5f563_fk_auth_user_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY django_admin_log + ADD CONSTRAINT django_admin_log_user_id_52fdd58701c5f563_fk_auth_user_id FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: extr_content_type_id_3d11dce08b0c7e23_fk_django_content_type_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY extras_exporttemplate + ADD CONSTRAINT extr_content_type_id_3d11dce08b0c7e23_fk_django_content_type_id FOREIGN KEY (content_type_id) REFERENCES django_content_type(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_aggregate_rir_id_6b95f7cbf861b265_fk_ipam_rir_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_aggregate + ADD CONSTRAINT ipam_aggregate_rir_id_6b95f7cbf861b265_fk_ipam_rir_id FOREIGN KEY (rir_id) REFERENCES ipam_rir(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_ipaddr_nat_inside_id_54e134739a4fce35_fk_ipam_ipaddress_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_ipaddress + ADD CONSTRAINT ipam_ipaddr_nat_inside_id_54e134739a4fce35_fk_ipam_ipaddress_id FOREIGN KEY (nat_inside_id) REFERENCES ipam_ipaddress(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_ipaddre_interface_id_1453a9dc6dd4107f_fk_dcim_interface_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_ipaddress + ADD CONSTRAINT ipam_ipaddre_interface_id_1453a9dc6dd4107f_fk_dcim_interface_id FOREIGN KEY (interface_id) REFERENCES dcim_interface(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_ipaddress_vrf_id_7961a6a27bac9dc0_fk_ipam_vrf_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_ipaddress + ADD CONSTRAINT ipam_ipaddress_vrf_id_7961a6a27bac9dc0_fk_ipam_vrf_id FOREIGN KEY (vrf_id) REFERENCES ipam_vrf(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_prefix_role_id_176ef537da785ba5_fk_ipam_role_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_prefix + ADD CONSTRAINT ipam_prefix_role_id_176ef537da785ba5_fk_ipam_role_id FOREIGN KEY (role_id) REFERENCES ipam_role(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_prefix_site_id_1256d3efdf9f08e8_fk_dcim_site_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_prefix + ADD CONSTRAINT ipam_prefix_site_id_1256d3efdf9f08e8_fk_dcim_site_id FOREIGN KEY (site_id) REFERENCES dcim_site(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_prefix_status_id_40a4d7159d040d2d_fk_ipam_status_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_prefix + ADD CONSTRAINT ipam_prefix_status_id_40a4d7159d040d2d_fk_ipam_status_id FOREIGN KEY (status_id) REFERENCES ipam_status(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_prefix_vlan_id_46c10e1ba4efd5ae_fk_ipam_vlan_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_prefix + ADD CONSTRAINT ipam_prefix_vlan_id_46c10e1ba4efd5ae_fk_ipam_vlan_id FOREIGN KEY (vlan_id) REFERENCES ipam_vlan(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_prefix_vrf_id_6a821d8b02f9f14c_fk_ipam_vrf_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_prefix + ADD CONSTRAINT ipam_prefix_vrf_id_6a821d8b02f9f14c_fk_ipam_vrf_id FOREIGN KEY (vrf_id) REFERENCES ipam_vrf(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_vlan_role_id_61511bbc81bb1474_fk_ipam_role_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_vlan + ADD CONSTRAINT ipam_vlan_role_id_61511bbc81bb1474_fk_ipam_role_id FOREIGN KEY (role_id) REFERENCES ipam_role(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_vlan_site_id_3d425e66fe6edb31_fk_dcim_site_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_vlan + ADD CONSTRAINT ipam_vlan_site_id_3d425e66fe6edb31_fk_dcim_site_id FOREIGN KEY (site_id) REFERENCES dcim_site(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: ipam_vlan_status_id_1e0407a8c04d0694_fk_ipam_status_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY ipam_vlan + ADD CONSTRAINT ipam_vlan_status_id_1e0407a8c04d0694_fk_ipam_status_id FOREIGN KEY (status_id) REFERENCES ipam_status(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: secrets_secr_content_type_id_07a52c0f_fk_django_content_type_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY secrets_secret + ADD CONSTRAINT secrets_secr_content_type_id_07a52c0f_fk_django_content_type_id FOREIGN KEY (content_type_id) REFERENCES django_content_type(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: secrets_secret_role_id_39d9347f_fk_secrets_secretrole_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY secrets_secret + ADD CONSTRAINT secrets_secret_role_id_39d9347f_fk_secrets_secretrole_id FOREIGN KEY (role_id) REFERENCES secrets_secretrole(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: secrets_userkey_user_id_13ada46b_fk_auth_user_id; Type: FK CONSTRAINT; Schema: public; Owner: django +-- + +ALTER TABLE ONLY secrets_userkey + ADD CONSTRAINT secrets_userkey_user_id_13ada46b_fk_auth_user_id FOREIGN KEY (user_id) REFERENCES auth_user(id) DEFERRABLE INITIALLY DEFERRED; + + +-- +-- Name: public; Type: ACL; Schema: -; Owner: postgres +-- + +REVOKE ALL ON SCHEMA public FROM PUBLIC; +REVOKE ALL ON SCHEMA public FROM postgres; +GRANT ALL ON SCHEMA public TO postgres; +GRANT ALL ON SCHEMA public TO PUBLIC; + + +-- +-- PostgreSQL database dump complete +-- + diff --git a/netbox/circuits/__init__.py b/netbox/circuits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py new file mode 100644 index 000000000..090ad7d39 --- /dev/null +++ b/netbox/circuits/admin.py @@ -0,0 +1,30 @@ +from django.contrib import admin + +from .models import Provider, CircuitType, Circuit + + +@admin.register(Provider) +class ProviderAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug', 'asn'] + + +@admin.register(CircuitType) +class CircuitTypeAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug'] + + +@admin.register(Circuit) +class CircuitAdmin(admin.ModelAdmin): + list_display = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id'] + list_filter = ['provider'] + exclude = ['interface'] + + def get_queryset(self, request): + qs = super(CircuitAdmin, self).get_queryset(request) + return qs.select_related('provider', 'type', 'site') diff --git a/netbox/circuits/api/__init__.py b/netbox/circuits/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py new file mode 100644 index 000000000..ebedda878 --- /dev/null +++ b/netbox/circuits/api/serializers.py @@ -0,0 +1,60 @@ +from rest_framework import serializers + +from circuits.models import Provider, CircuitType, Circuit +from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer + + +# +# Providers +# + +class ProviderSerializer(serializers.ModelSerializer): + + class Meta: + model = Provider + fields = ['id', 'name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + + +class ProviderNestedSerializer(ProviderSerializer): + + class Meta(ProviderSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# Circuit types +# + +class CircuitTypeSerializer(serializers.ModelSerializer): + + class Meta: + model = CircuitType + fields = ['id', 'name', 'slug'] + + +class CircuitTypeNestedSerializer(CircuitTypeSerializer): + + class Meta(CircuitTypeSerializer.Meta): + pass + + +# +# Circuits +# + +class CircuitSerializer(serializers.ModelSerializer): + provider = ProviderNestedSerializer() + type = CircuitTypeNestedSerializer() + site = SiteNestedSerializer() + interface = InterfaceNestedSerializer() + + class Meta: + model = Circuit + fields = ['id', 'cid', 'provider', 'type', 'site', 'interface', 'install_date', 'port_speed', 'commit_rate', + 'xconnect_id', 'comments'] + + +class CircuitNestedSerializer(CircuitSerializer): + + class Meta(CircuitSerializer.Meta): + fields = ['id', 'cid'] diff --git a/netbox/circuits/api/urls.py b/netbox/circuits/api/urls.py new file mode 100644 index 000000000..ef45b07ed --- /dev/null +++ b/netbox/circuits/api/urls.py @@ -0,0 +1,24 @@ +from django.conf.urls import url + +from extras.models import GRAPH_TYPE_PROVIDER +from extras.api.views import GraphListView + +from .views import * + + +urlpatterns = [ + + # Providers + url(r'^providers/$', ProviderListView.as_view(), name='provider_list'), + url(r'^providers/(?P\d+)/$', ProviderDetailView.as_view(), name='provider_detail'), + url(r'^providers/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_PROVIDER}, name='provider_graphs'), + + # Circuit types + url(r'^circuit-types/$', CircuitTypeListView.as_view(), name='circuittype_list'), + url(r'^circuit-types/(?P\d+)/$', CircuitTypeDetailView.as_view(), name='circuittype_detail'), + + # Circuits + url(r'^circuits/$', CircuitListView.as_view(), name='circuit_list'), + url(r'^circuits/(?P\d+)/$', CircuitDetailView.as_view(), name='circuit_detail'), + +] diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py new file mode 100644 index 000000000..834c9b957 --- /dev/null +++ b/netbox/circuits/api/views.py @@ -0,0 +1,54 @@ +from rest_framework import generics + +from circuits.models import Provider, CircuitType, Circuit +from circuits.filters import CircuitFilter +from .serializers import ProviderSerializer, CircuitTypeSerializer, CircuitSerializer + + +class ProviderListView(generics.ListAPIView): + """ + List all providers + """ + queryset = Provider.objects.all() + serializer_class = ProviderSerializer + + +class ProviderDetailView(generics.RetrieveAPIView): + """ + Retrieve a single provider + """ + queryset = Provider.objects.all() + serializer_class = ProviderSerializer + + +class CircuitTypeListView(generics.ListAPIView): + """ + List all circuit types + """ + queryset = CircuitType.objects.all() + serializer_class = CircuitTypeSerializer + + +class CircuitTypeDetailView(generics.RetrieveAPIView): + """ + Retrieve a single circuit type + """ + queryset = CircuitType.objects.all() + serializer_class = CircuitTypeSerializer + + +class CircuitListView(generics.ListAPIView): + """ + List circuits (filterable) + """ + queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device') + serializer_class = CircuitSerializer + filter_class = CircuitFilter + + +class CircuitDetailView(generics.RetrieveAPIView): + """ + Retrieve a single circuit + """ + queryset = Circuit.objects.select_related('type', 'provider', 'site', 'interface__device') + serializer_class = CircuitSerializer diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py new file mode 100644 index 000000000..8645d34eb --- /dev/null +++ b/netbox/circuits/filters.py @@ -0,0 +1,52 @@ +import django_filters + +from dcim.models import Site +from circuits.models import Provider, Circuit, CircuitType + + +class CircuitFilter(django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) + provider_id = django_filters.ModelMultipleChoiceFilter( + name='provider', + queryset=Provider.objects.all(), + label='Provider (ID)', + ) + provider = django_filters.ModelMultipleChoiceFilter( + name='provider', + queryset=Provider.objects.all(), + to_field_name='slug', + label='Provider (slug)', + ) + type_id = django_filters.ModelMultipleChoiceFilter( + name='type', + queryset=CircuitType.objects.all(), + label='Circuit type (ID)', + ) + type = django_filters.ModelMultipleChoiceFilter( + name='type', + queryset=CircuitType.objects.all(), + to_field_name='slug', + label='Circuit type (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = Circuit + fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date'] + + def search(self, queryset, value): + value = value.strip() + return queryset.filter(cid__icontains=value) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py new file mode 100644 index 000000000..db099fc63 --- /dev/null +++ b/netbox/circuits/forms.py @@ -0,0 +1,191 @@ +from django import forms +from django.db.models import Count + +from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL +from utilities.forms import BootstrapMixin, SmallTextarea, ConfirmationForm, APISelect, Livesearch + +from .models import PORT_SPEED_CHOICES, Circuit, Provider, CircuitType +from utilities.forms import CommentField, CSVDataField, BulkImportForm + + +# +# Providers +# + +class ProviderForm(forms.ModelForm, BootstrapMixin): + comments = CommentField() + + class Meta: + model = Provider + fields = ['name', 'slug', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments'] + widgets = { + 'noc_contact': SmallTextarea(attrs={'rows': 5}), + 'admin_contact': SmallTextarea(attrs={'rows': 5}), + } + help_texts = { + 'name': "Full name of the provider", + 'slug': "URL-friendly unique shorthand (e.g. 'decix' for DE-CIX)", + 'asn': "BGP autonomous system number (if applicable)", + 'portal_url': "URL of the provider's customer support portal", + 'noc_contact': "NOC email address and phone number", + 'admin_contact': "Administrative contact email address and phone number", + } + + +class ProviderFromCSVForm(forms.ModelForm): + + class Meta: + model = Provider + fields = ['name', 'slug', 'asn', 'account', 'portal_url'] + + +class ProviderImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=ProviderFromCSVForm) + + +class ProviderBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) + asn = forms.IntegerField(required=False, label='ASN') + account = forms.CharField(max_length=30, required=False, label='Account number') + portal_url = forms.URLField(required=False, label='Portal') + noc_contact = forms.CharField(required=False, widget=SmallTextarea, label='NOC contact') + admin_contact = forms.CharField(required=False, widget=SmallTextarea, label='Admin contact') + comments = CommentField() + + +class ProviderBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Provider.objects.all(), widget=forms.MultipleHiddenInput) + + +# +# Circuits +# + +class CircuitForm(forms.ModelForm, BootstrapMixin): + site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack', + widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', + attrs={'filter-for': 'device'})) + device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', + widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', + attrs={'filter-for': 'interface'})) + livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='device') + ) + interface = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface', + widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', + disabled_indicator='is_connected')) + comments = CommentField() + + class Meta: + model = Circuit + fields = [ + 'cid', 'type', 'provider', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date', + 'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments' + ] + help_texts = { + 'cid': "Unique circuit ID", + 'install_date': "Format: YYYY-MM-DD", + 'port_speed': "Physical circuit speed", + 'commit_rate': "Commited rate (in Mbps)", + 'xconnect_id': "ID of the local cross-connect", + 'pp_info': "Patch panel ID and port number(s)" + } + + def __init__(self, *args, **kwargs): + + super(CircuitForm, self).__init__(*args, **kwargs) + + # If this circuit has been assigned to an interface, initialize rack and device + if self.instance.interface: + self.initial['rack'] = self.instance.interface.device.rack + self.initial['device'] = self.instance.interface.device + + # Limit rack choices + if self.is_bound: + self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) + elif self.initial.get('site'): + self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) + else: + self.fields['rack'].choices = [] + + # Limit device choices + if self.is_bound and self.data.get('rack'): + self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack']) + elif self.initial.get('rack'): + self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) + else: + self.fields['device'].choices = [] + + # Limit interface choices + if self.is_bound and self.data.get('device'): + interfaces = Interface.objects.filter(device=self.data['device'])\ + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') + elif self.initial.get('device'): + interfaces = Interface.objects.filter(device=self.initial['device'])\ + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') + else: + interfaces = [] + self.fields['interface'].choices = [ + (iface.id, { + 'label': iface.name, + 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'), + }) for iface in interfaces + ] + + +class CircuitFromCSVForm(forms.ModelForm): + provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Provider not found.'}) + type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid circuit type.'}) + site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Site not found.'}) + + class Meta: + model = Circuit + fields = ['cid', 'provider', 'type', 'site', 'install_date', 'port_speed', 'commit_rate', 'xconnect_id', + 'pp_info'] + + +class CircuitImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=CircuitFromCSVForm) + + +class CircuitBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) + type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) + provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) + port_speed = forms.ChoiceField(choices=[(None, '---------')] + PORT_SPEED_CHOICES, required=False, + label='Port speed') + commit_rate = forms.IntegerField(required=False, label='Commit rate (Mbps)') + comments = CommentField() + + +class CircuitBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) + + +def circuit_type_choices(): + type_choices = CircuitType.objects.annotate(circuit_count=Count('circuits')) + return [(t.slug, '{} ({})'.format(t.name, t.circuit_count)) for t in type_choices] + + +def circuit_provider_choices(): + provider_choices = Provider.objects.annotate(circuit_count=Count('circuits')) + return [(p.slug, '{} ({})'.format(p.name, p.circuit_count)) for p in provider_choices] + + +def circuit_site_choices(): + site_choices = Site.objects.annotate(circuit_count=Count('circuits')) + return [(s.slug, '{} ({})'.format(s.name, s.circuit_count)) for s in site_choices] + + +class CircuitFilterForm(forms.Form, BootstrapMixin): + type = forms.MultipleChoiceField(required=False, choices=circuit_type_choices) + provider = forms.MultipleChoiceField(required=False, choices=circuit_provider_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + site = forms.MultipleChoiceField(required=False, choices=circuit_site_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/circuits/migrations/0001_initial.py b/netbox/circuits/migrations/0001_initial.py new file mode 100644 index 000000000..8136eac9a --- /dev/null +++ b/netbox/circuits/migrations/0001_initial.py @@ -0,0 +1,82 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-27 02:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '__first__'), + ] + + operations = [ + migrations.CreateModel( + name='Circuit', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('cid', models.CharField(max_length=50, verbose_name=b'Circuit ID')), + ('install_date', models.DateField(blank=True, null=True, verbose_name=b'Date installed')), + ('port_speed', models.PositiveSmallIntegerField(choices=[[100, b'100 Mbps'], [1000, b'1 Gbps'], [10000, b'10 Gbps'], [25000, b'25 Gbps'], [40000, b'40 Gbps'], [50000, b'50 Gbps'], [100000, b'100 Gbps']], verbose_name=b'Port speed')), + ('commit_rate', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'Commit rate (Mbps)')), + ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')), + ('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')), + ('comments', models.TextField(blank=True)), + ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='circuit', to='dcim.Interface')), + ], + options={ + 'ordering': ['provider', 'cid'], + }, + ), + migrations.CreateModel( + name='CircuitType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Provider', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('asn', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'ASN')), + ('account', models.CharField(blank=True, max_length=30, verbose_name=b'Account number')), + ('portal_url', models.URLField(blank=True, verbose_name=b'Portal')), + ('noc_contact', models.TextField(blank=True, verbose_name=b'NOC Contact')), + ('admin_contact', models.TextField(blank=True, verbose_name=b'Admin Contact')), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='circuit', + name='provider', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.Provider'), + ), + migrations.AddField( + model_name='circuit', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='dcim.Site'), + ), + migrations.AddField( + model_name='circuit', + name='type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuits', to='circuits.CircuitType'), + ), + migrations.AlterUniqueTogether( + name='circuit', + unique_together=set([('provider', 'cid')]), + ), + ] diff --git a/netbox/circuits/migrations/__init__.py b/netbox/circuits/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py new file mode 100644 index 000000000..d26bbac03 --- /dev/null +++ b/netbox/circuits/models.py @@ -0,0 +1,86 @@ +from django.core.urlresolvers import reverse +from django.db import models + +from dcim.models import Site, Interface + + +PORT_SPEED_100M = 100 +PORT_SPEED_1G = 1000 +PORT_SPEED_10G = 10000 +PORT_SPEED_25G = 25000 +PORT_SPEED_40G = 40000 +PORT_SPEED_50G = 50000 +PORT_SPEED_100G = 100000 +PORT_SPEED_CHOICES = [ + [PORT_SPEED_100M, '100 Mbps'], + [PORT_SPEED_1G, '1 Gbps'], + [PORT_SPEED_10G, '10 Gbps'], + [PORT_SPEED_25G, '25 Gbps'], + [PORT_SPEED_40G, '40 Gbps'], + [PORT_SPEED_50G, '50 Gbps'], + [PORT_SPEED_100G, '100 Gbps'], +] + + +class Provider(models.Model): + """ + A transit provider, IX, or direct peer + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN') + account = models.CharField(max_length=30, blank=True, verbose_name='Account number') + portal_url = models.URLField(blank=True, verbose_name='Portal') + noc_contact = models.TextField(blank=True, verbose_name='NOC Contact') + admin_contact = models.TextField(blank=True, verbose_name='Admin Contact') + comments = models.TextField(blank=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + def get_absolute_url(self): + return reverse('circuits:provider', args=[self.slug]) + + +class CircuitType(models.Model): + """ + A type of circuit + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class Circuit(models.Model): + """ + A data circuit from a site to a provider (includes IX connections) + """ + cid = models.CharField(max_length=50, verbose_name='Circuit ID') + provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) + type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) + site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT) + interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True) + install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') + port_speed = models.PositiveSmallIntegerField(choices=PORT_SPEED_CHOICES, verbose_name='Port speed') + commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Mbps)') + xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') + pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') + comments = models.TextField(blank=True) + + class Meta: + ordering = ['provider', 'cid'] + unique_together = ['provider', 'cid'] + + def __unicode__(self): + return "{0} {1}".format(self.provider, self.cid) + + def get_absolute_url(self): + return reverse('circuits:circuit', args=[self.pk]) diff --git a/netbox/circuits/tables.py b/netbox/circuits/tables.py new file mode 100644 index 000000000..1aa951408 --- /dev/null +++ b/netbox/circuits/tables.py @@ -0,0 +1,59 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from .models import Circuit, Provider + + +# +# Providers +# + +class ProviderTable(tables.Table): + name = tables.LinkColumn('circuits:provider', args=[Accessor('slug')], verbose_name='Name') + asn = tables.Column(verbose_name='ASN') + circuit_count = tables.Column(accessor=Accessor('count_circuits'), verbose_name='Circuits') + + class Meta: + model = Provider + fields = ('name', 'asn', 'circuit_count') + empty_text = "No providers found." + attrs = { + 'class': 'table table-hover', + } + + +class ProviderBulkEditTable(ProviderTable): + pk = tables.CheckBoxColumn() + + class Meta(ProviderTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'name', 'asn', 'circuit_count') + + +# +# Circuits +# + +class CircuitTable(tables.Table): + cid = tables.LinkColumn('circuits:circuit', args=[Accessor('pk')], verbose_name='ID') + type = tables.Column(verbose_name='Type') + provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + port_speed = tables.Column(verbose_name='Port Speed') + commit_rate = tables.Column(verbose_name='Commit (Mbps)') + + class Meta: + model = Circuit + fields = ('cid', 'type', 'provider', 'site', 'port_speed', 'commit_rate') + empty_text = "No circuits found." + attrs = { + 'class': 'table table-hover', + } + + +class CircuitBulkEditTable(CircuitTable): + pk = tables.CheckBoxColumn() + + class Meta(CircuitTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'cid', 'type', 'provider', 'site', 'port_speed', 'commit_rate') diff --git a/netbox/circuits/tests.py b/netbox/circuits/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/netbox/circuits/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py new file mode 100644 index 000000000..b700d95bf --- /dev/null +++ b/netbox/circuits/urls.py @@ -0,0 +1,23 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^circuits/$', views.circuit_list, name='circuit_list'), + url(r'^circuits/add/$', views.circuit_add, name='circuit_add'), + url(r'^circuits/import/$', views.CircuitBulkImportView.as_view(), name='circuit_import'), + url(r'^circuits/edit/$', views.CircuitBulkEditView.as_view(), name='circuit_bulk_edit'), + url(r'^circuits/delete/$', views.CircuitBulkDeleteView.as_view(), name='circuit_bulk_delete'), + url(r'^circuits/(?P\d+)/$', views.circuit, name='circuit'), + url(r'^circuits/(?P\d+)/edit/$', views.circuit_edit, name='circuit_edit'), + url(r'^circuits/(?P\d+)/delete/$', views.circuit_delete, name='circuit_delete'), + + url(r'^providers/$', views.provider_list, name='provider_list'), + url(r'^providers/add/$', views.provider_add, name='provider_add'), + url(r'^providers/import/$', views.ProviderBulkImportView.as_view(), name='provider_import'), + url(r'^providers/edit/$', views.ProviderBulkEditView.as_view(), name='provider_bulk_edit'), + url(r'^providers/delete/$', views.ProviderBulkDeleteView.as_view(), name='provider_bulk_delete'), + url(r'^providers/(?P[\w-]+)/$', views.provider, name='provider'), + url(r'^providers/(?P[\w-]+)/edit/$', views.provider_edit, name='provider_edit'), + url(r'^providers/(?P[\w-]+)/delete/$', views.provider_delete, name='provider_delete'), +] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py new file mode 100644 index 000000000..191bf2d23 --- /dev/null +++ b/netbox/circuits/views.py @@ -0,0 +1,309 @@ +from django_tables2 import RequestConfig + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.urlresolvers import reverse +from django.db.models import Count, ProtectedError +from django.shortcuts import get_object_or_404, redirect, render + +from extras.models import ExportTemplate +from utilities.error_handlers import handle_protectederror +from utilities.forms import ConfirmationForm +from utilities.paginator import EnhancedPaginator +from utilities.views import BulkImportView, BulkEditView, BulkDeleteView + +from .filters import CircuitFilter +from .forms import CircuitForm, CircuitImportForm, CircuitBulkEditForm, CircuitBulkDeleteForm, CircuitFilterForm, \ + ProviderForm, ProviderImportForm, ProviderBulkEditForm, ProviderBulkDeleteForm +from .models import Circuit, Provider +from .tables import CircuitTable, CircuitBulkEditTable, ProviderTable, ProviderBulkEditTable + + +# +# Providers +# + +def provider_list(request): + + queryset = Provider.objects.annotate(count_circuits=Count('circuits')) + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='provider', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_providers') + return response + + if request.user.has_perm('circuits.change_provider') or request.user.has_perm('circuits.delete_provider'): + provider_table = ProviderBulkEditTable(queryset) + else: + provider_table = ProviderTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(provider_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='provider') + + return render(request, 'circuits/provider_list.html', { + 'provider_table': provider_table, + 'export_templates': export_templates, + }) + + +def provider(request, slug): + + provider = get_object_or_404(Provider, slug=slug) + circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device') + + return render(request, 'circuits/provider.html', { + 'provider': provider, + 'circuits': circuits, + }) + + +@permission_required('circuits.add_provider') +def provider_add(request): + + if request.method == 'POST': + form = ProviderForm(request.POST) + if form.is_valid(): + provider = form.save() + messages.success(request, "Added new provider: {0}".format(provider)) + if '_addanother' in request.POST: + return redirect('circuits:provider_add') + else: + return redirect('circuits:provider', slug=provider.slug) + + else: + form = ProviderForm() + + return render(request, 'circuits/provider_edit.html', { + 'form': form, + 'cancel_url': reverse('circuits:provider_list'), + }) + + +@permission_required('circuits.change_provider') +def provider_edit(request, slug): + + provider = get_object_or_404(Provider, slug=slug) + + if request.method == 'POST': + form = ProviderForm(request.POST, instance=provider) + if form.is_valid(): + provider = form.save() + messages.success(request, "Modified provider {0}".format(provider)) + return redirect('circuits:provider', slug=provider.slug) + + else: + form = ProviderForm(instance=provider) + + return render(request, 'circuits/provider_edit.html', { + 'provider': provider, + 'form': form, + 'cancel_url': reverse('circuits:provider', kwargs={'slug': provider.slug}), + }) + + +@permission_required('circuits.delete_provider') +def provider_delete(request, slug): + + provider = get_object_or_404(Provider, slug=slug) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + provider.delete() + messages.success(request, "Provider {0} has been deleted".format(provider)) + return redirect('circuits:provider_list') + except ProtectedError, e: + handle_protectederror(provider, request, e) + return redirect('circuits:provider', slug=provider.slug) + + else: + form = ConfirmationForm() + + return render(request, 'circuits/provider_delete.html', { + 'provider': provider, + 'form': form, + 'cancel_url': reverse('circuits:provider', kwargs={'slug': provider.slug}) + }) + + +class ProviderBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'circuits.add_provider' + form = ProviderImportForm + table = ProviderTable + template_name = 'circuits/provider_import.html' + obj_list_url = 'circuits:provider_list' + + +class ProviderBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'circuits.change_provider' + cls = Provider + form = ProviderBulkEditForm + template_name = 'circuits/provider_bulk_edit.html' + redirect_url = 'circuits:provider_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'comments']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} providers".format(updated_count)) + + +class ProviderBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'circuits.delete_provider' + cls = Provider + form = ProviderBulkDeleteForm + template_name = 'circuits/provider_bulk_delete.html' + redirect_url = 'circuits:provider_list' + + +# +# Circuits +# + +def circuit_list(request): + + queryset = Circuit.objects.select_related('provider', 'type', 'site') + queryset = CircuitFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='circuit', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_circuits') + return response + + if request.user.has_perm('circuits.change_circuit') or request.user.has_perm('circuits.delete_circuit'): + circuit_table = CircuitBulkEditTable(queryset) + else: + circuit_table = CircuitTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(circuit_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='circuit') + + return render(request, 'circuits/circuit_list.html', { + 'circuit_table': circuit_table, + 'export_templates': export_templates, + 'filter_form': CircuitFilterForm(request.GET, label_suffix=''), + }) + + +def circuit(request, pk): + + circuit = get_object_or_404(Circuit, pk=pk) + + return render(request, 'circuits/circuit.html', { + 'circuit': circuit, + }) + + +@permission_required('circuits.add_circuit') +def circuit_add(request): + + if request.method == 'POST': + form = CircuitForm(request.POST) + if form.is_valid(): + circuit = form.save() + messages.success(request, "Added new circuit: {0}".format(circuit)) + if '_addanother' in request.POST: + return redirect('circuits:circuit_add') + else: + return redirect('circuits:circuit', pk=circuit.pk) + + else: + form = CircuitForm(initial={ + 'site': request.GET.get('site'), + }) + + return render(request, 'circuits/circuit_edit.html', { + 'form': form, + 'cancel_url': reverse('circuits:circuit_list'), + }) + + +@permission_required('circuits.change_circuit') +def circuit_edit(request, pk): + + circuit = get_object_or_404(Circuit, pk=pk) + + if request.method == 'POST': + form = CircuitForm(request.POST, instance=circuit) + if form.is_valid(): + circuit = form.save() + messages.success(request, "Modified circuit {0}".format(circuit)) + return redirect('circuits:circuit', pk=circuit.pk) + + else: + form = CircuitForm(instance=circuit) + + return render(request, 'circuits/circuit_edit.html', { + 'circuit': circuit, + 'form': form, + 'cancel_url': reverse('circuits:circuit', kwargs={'pk': circuit.pk}), + }) + + +@permission_required('circuits.delete_circuit') +def circuit_delete(request, pk): + + circuit = get_object_or_404(Circuit, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + circuit.delete() + messages.success(request, "Circuit {0} has been deleted".format(circuit)) + return redirect('circuits:circuit_list') + except ProtectedError, e: + handle_protectederror(circuit, request, e) + return redirect('circuits:circuit', pk=circuit.pk) + + else: + form = ConfirmationForm() + + return render(request, 'circuits/circuit_delete.html', { + 'circuit': circuit, + 'form': form, + 'cancel_url': reverse('circuits:circuit', kwargs={'pk': circuit.pk}) + }) + + +class CircuitBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'circuits.add_circuit' + form = CircuitImportForm + table = CircuitTable + template_name = 'circuits/circuit_import.html' + obj_list_url = 'circuits:circuit_list' + + +class CircuitBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'circuits.change_circuit' + cls = Circuit + form = CircuitBulkEditForm + template_name = 'circuits/circuit_bulk_edit.html' + redirect_url = 'circuits:circuit_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['type', 'provider', 'port_speed', 'commit_rate', 'comments']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} circuits".format(updated_count)) + + +class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'circuits.delete_circuit' + cls = Circuit + form = CircuitBulkDeleteForm + template_name = 'circuits/circuit_bulk_delete.html' + redirect_url = 'circuits:circuit_list' diff --git a/netbox/dcim/__init__.py b/netbox/dcim/__init__.py new file mode 100644 index 000000000..ef0a6f38a --- /dev/null +++ b/netbox/dcim/__init__.py @@ -0,0 +1 @@ +default_app_config = 'dcim.apps.IPAMConfig' \ No newline at end of file diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py new file mode 100644 index 000000000..257ca542f --- /dev/null +++ b/netbox/dcim/admin.py @@ -0,0 +1,161 @@ +from django.contrib import admin +from django.db.models import Count + +from .models import * + + +@admin.register(Site) +class SiteAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'facility', 'asn'] + prepopulated_fields = { + 'slug': ['name'], + } + + +@admin.register(RackGroup) +class RackGroupAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'site'] + prepopulated_fields = { + 'slug': ['name'], + } + + +@admin.register(Rack) +class RackAdmin(admin.ModelAdmin): + list_display = ['name', 'facility_id', 'site', 'u_height'] + + +# +# Device types +# + +@admin.register(Manufacturer) +class ManufacturerAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug'] + + +class ConsolePortTemplateAdmin(admin.TabularInline): + model = ConsolePortTemplate + + +class ConsoleServerPortTemplateAdmin(admin.TabularInline): + model = ConsoleServerPortTemplate + + +class PowerPortTemplateAdmin(admin.TabularInline): + model = PowerPortTemplate + + +class PowerOutletTemplateAdmin(admin.TabularInline): + model = PowerOutletTemplate + + +class InterfaceTemplateAdmin(admin.TabularInline): + model = InterfaceTemplate + + +@admin.register(DeviceType) +class DeviceTypeAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['model'], + } + inlines = [ + ConsolePortTemplateAdmin, + ConsoleServerPortTemplateAdmin, + PowerPortTemplateAdmin, + PowerOutletTemplateAdmin, + InterfaceTemplateAdmin, + ] + list_display = ['model', 'manufacturer', 'slug', 'u_height', 'console_ports', 'console_server_ports', 'power_ports', + 'power_outlets', 'interfaces'] + list_filter = ['manufacturer'] + + def get_queryset(self, request): + return DeviceType.objects.annotate( + console_port_count=Count('console_port_templates', distinct=True), + cs_port_count=Count('cs_port_templates', distinct=True), + power_port_count=Count('power_port_templates', distinct=True), + power_outlet_count=Count('power_outlet_templates', distinct=True), + interface_count=Count('interface_templates', distinct=True), + ) + + def console_ports(self, instance): + return instance.console_port_count + + def console_server_ports(self, instance): + return instance.cs_port_count + + def power_ports(self, instance): + return instance.power_port_count + + def power_outlets(self, instance): + return instance.power_outlet_count + + def interfaces(self, instance): + return instance.interface_count + + +# +# Devices +# + +@admin.register(DeviceRole) +class DeviceRoleAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug', 'color'] + + +@admin.register(Platform) +class PlatformAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'rpc_client'] + + +class ConsolePortAdmin(admin.TabularInline): + model = ConsolePort + readonly_fields = ['cs_port'] + + +class ConsoleServerPortAdmin(admin.TabularInline): + model = ConsoleServerPort + + +class PowerPortAdmin(admin.TabularInline): + model = PowerPort + readonly_fields = ['power_outlet'] + + +class PowerOutletAdmin(admin.TabularInline): + model = PowerOutlet + + +class InterfaceAdmin(admin.TabularInline): + model = Interface + + +class ModuleAdmin(admin.TabularInline): + model = Module + +@admin.register(Device) +class DeviceAdmin(admin.ModelAdmin): + inlines = [ + ConsolePortAdmin, + ConsoleServerPortAdmin, + PowerPortAdmin, + PowerOutletAdmin, + InterfaceAdmin, + ModuleAdmin, + ] + list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial'] + list_filter = ['device_role'] + + def get_queryset(self, request): + qs = super(DeviceAdmin, self).get_queryset(request) + return qs.select_related('device_type__manufacturer', 'device_role', 'primary_ip', 'rack') diff --git a/netbox/dcim/api/__init__.py b/netbox/dcim/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/api/exceptions.py b/netbox/dcim/api/exceptions.py new file mode 100644 index 000000000..05ad86b5b --- /dev/null +++ b/netbox/dcim/api/exceptions.py @@ -0,0 +1,6 @@ +from rest_framework.exceptions import APIException + + +class MissingFilterException(APIException): + status_code = 400 + default_detail = "One or more required filters is missing from the request." diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py new file mode 100644 index 000000000..c605559c3 --- /dev/null +++ b/netbox/dcim/api/serializers.py @@ -0,0 +1,300 @@ +from rest_framework import serializers + +from ipam.models import IPAddress +from dcim.models import Site, Rack, RackGroup, Manufacturer, DeviceType, DeviceRole, Platform, Device, ConsolePort,\ + ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, RACK_FACE_FRONT, RACK_FACE_REAR + + +# +# Sites +# + +class SiteSerializer(serializers.ModelSerializer): + + class Meta: + model = Site + fields = ['id', 'name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments', + 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] + + +class SiteNestedSerializer(SiteSerializer): + + class Meta(SiteSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# Rack groups +# + +class RackGroupSerializer(serializers.ModelSerializer): + site = SiteNestedSerializer() + + class Meta: + model = RackGroup + fields = ['id', 'name', 'slug', 'site'] + + +class RackGroupNestedSerializer(SiteSerializer): + + class Meta(SiteSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# Racks +# + + +class RackSerializer(serializers.ModelSerializer): + display_name = serializers.SerializerMethodField() + site = SiteNestedSerializer() + group = RackGroupNestedSerializer() + + class Meta: + model = Rack + fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments'] + + def get_display_name(self, obj): + return str(obj) + + +class RackNestedSerializer(RackSerializer): + + class Meta(RackSerializer.Meta): + fields = ['id', 'name', 'facility_id', 'display_name'] + + +class RackDetailSerializer(RackSerializer): + front_units = serializers.SerializerMethodField() + rear_units = serializers.SerializerMethodField() + + class Meta(RackSerializer.Meta): + fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'u_height', 'comments', 'front_units', + 'rear_units'] + + def get_front_units(self, obj): + units = obj.get_rack_units(face=RACK_FACE_FRONT) + for u in units: + u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None + return units + + def get_rear_units(self, obj): + units = obj.get_rack_units(face=RACK_FACE_REAR) + for u in units: + u['device'] = DeviceNestedSerializer(u['device']).data if u['device'] else None + return units + + +# +# Manufacturers +# + +class ManufacturerSerializer(serializers.ModelSerializer): + + class Meta: + model = Manufacturer + fields = ['id', 'name', 'slug'] + + +class ManufacturerNestedSerializer(ManufacturerSerializer): + + class Meta(ManufacturerSerializer.Meta): + pass + + +# +# Device types +# + +class DeviceTypeSerializer(serializers.ModelSerializer): + manufacturer = ManufacturerNestedSerializer() + + class Meta: + model = DeviceType + fields = ['id', 'manufacturer', 'model', 'slug', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device'] + + +class DeviceTypeNestedSerializer(DeviceTypeSerializer): + + class Meta(DeviceTypeSerializer.Meta): + fields = ['id', 'manufacturer', 'model', 'slug'] + + +# +# Device roles +# + +class DeviceRoleSerializer(serializers.ModelSerializer): + + class Meta: + model = DeviceRole + fields = ['id', 'name', 'slug', 'color'] + + +class DeviceRoleNestedSerializer(DeviceRoleSerializer): + + class Meta(DeviceRoleSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# Platforms +# + +class PlatformSerializer(serializers.ModelSerializer): + + class Meta: + model = Platform + fields = ['id', 'name', 'slug', 'rpc_client'] + + +class PlatformNestedSerializer(PlatformSerializer): + + class Meta(PlatformSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# Devices +# + +# Cannot import ipam.api.IPAddressNestedSerializer due to circular dependency +class DeviceIPAddressNestedSerializer(serializers.ModelSerializer): + + class Meta: + model = IPAddress + fields = ['id', 'family', 'address'] + + +class DeviceSerializer(serializers.ModelSerializer): + device_type = DeviceTypeNestedSerializer() + device_role = DeviceRoleNestedSerializer() + platform = PlatformNestedSerializer() + rack = RackNestedSerializer() + primary_ip = DeviceIPAddressNestedSerializer() + + class Meta: + model = Device + fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'platform', 'serial', 'rack', 'position', + 'face', 'status', 'primary_ip', 'ro_snmp', 'comments'] + + +class DeviceNestedSerializer(DeviceSerializer): + + class Meta(DeviceSerializer.Meta): + model = Device + fields = ['id', 'name'] + + +# +# Console server ports +# + +class ConsoleServerPortSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + + class Meta: + model = ConsoleServerPort + fields = ['id', 'device', 'name', 'connected_console'] + + +class ConsoleServerPortNestedSerializer(ConsoleServerPortSerializer): + + class Meta(ConsoleServerPortSerializer.Meta): + fields = ['id', 'device', 'name'] + + +# +# Console ports +# + +class ConsolePortSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + cs_port = ConsoleServerPortNestedSerializer() + + class Meta: + model = ConsolePort + fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] + + +class ConsolePortNestedSerializer(ConsolePortSerializer): + + class Meta(ConsolePortSerializer.Meta): + fields = ['id', 'device', 'name'] + + +# +# Power outlets +# + +class PowerOutletSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + + class Meta: + model = PowerOutlet + fields = ['id', 'device', 'name', 'connected_port'] + + +class PowerOutletNestedSerializer(PowerOutletSerializer): + + class Meta(PowerOutletSerializer.Meta): + fields = ['id', 'device', 'name'] + + +# +# Power ports +# + +class PowerPortSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + power_outlet = PowerOutletNestedSerializer() + + class Meta: + model = PowerPort + fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] + + +class PowerPortNestedSerializer(PowerPortSerializer): + + class Meta(PowerPortSerializer.Meta): + fields = ['id', 'device', 'name'] + + +# +# Interfaces +# + +class InterfaceSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + form_factor = serializers.ReadOnlyField(source='get_form_factor_display') + + class Meta: + model = Interface + fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected'] + + +class InterfaceNestedSerializer(InterfaceSerializer): + form_factor = serializers.ReadOnlyField(source='get_form_factor_display') + + class Meta(InterfaceSerializer.Meta): + fields = ['id', 'device', 'name'] + + +class InterfaceDetailSerializer(InterfaceSerializer): + connected_interface = InterfaceSerializer(source='get_connected_interface') + + class Meta(InterfaceSerializer.Meta): + fields = ['id', 'device', 'name', 'form_factor', 'mgmt_only', 'description', 'is_connected', + 'connected_interface'] + + +# +# Interface connections +# + +class InterfaceConnectionSerializer(serializers.ModelSerializer): + + class Meta: + model = InterfaceConnection + fields = ['id', 'interface_a', 'interface_b', 'connection_status'] diff --git a/netbox/dcim/api/tests.py b/netbox/dcim/api/tests.py new file mode 100644 index 000000000..b6bd72051 --- /dev/null +++ b/netbox/dcim/api/tests.py @@ -0,0 +1,581 @@ +import json +from rest_framework import status +from rest_framework.test import APITestCase + + +class SiteTest(APITestCase): + + fixtures = [ + 'dcim', + 'ipam', + 'extras', + ] + + standard_fields = [ + 'id', + 'name', + 'slug', + 'facility', + 'asn', + 'physical_address', + 'shipping_address', + 'comments', + 'count_prefixes', + 'count_vlans', + 'count_racks', + 'count_devices', + 'count_circuits' + ] + + nested_fields = [ + 'id', + 'name', + 'slug' + ] + + rack_fields = [ + 'id', + 'name', + 'facility_id', + 'display_name', + 'site', + 'group', + 'u_height', + 'comments' + ] + + graph_fields = [ + 'name', + 'embed_url', + 'link', + ] + + def test_get_list(self, endpoint='/api/dcim/sites/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/sites/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + + def test_get_site_list_rack(self, endpoint='/api/dcim/sites/1/racks/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in json.loads(response.content): + self.assertEqual( + sorted(i.keys()), + sorted(self.rack_fields), + ) + # Check Nested Serializer. + self.assertEqual( + sorted(i.get('site').keys()), + sorted(self.nested_fields), + ) + + def test_get_site_list_graphs(self, endpoint='/api/dcim/sites/1/graphs/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in json.loads(response.content): + self.assertEqual( + sorted(i.keys()), + sorted(self.graph_fields), + ) + + +class RackTest(APITestCase): + fixtures = [ + 'dcim', + 'ipam' + ] + + nested_fields = [ + 'id', + 'name', + 'facility_id', + 'display_name' + ] + + standard_fields = [ + 'id', + 'name', + 'facility_id', + 'display_name', + 'site', + 'group', + 'u_height', + 'comments' + ] + + detail_fields = [ + 'id', + 'name', + 'facility_id', + 'display_name', + 'site', + 'group', + 'u_height', + 'comments', + 'front_units', + 'rear_units' + ] + + def test_get_list(self, endpoint='/api/dcim/racks/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(i.get('site').keys()), + sorted(SiteTest.nested_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/racks/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.detail_fields), + ) + self.assertEqual( + sorted(content.get('site').keys()), + sorted(SiteTest.nested_fields), + ) + + +class ManufacturersTest(APITestCase): + + fixtures = [ + 'dcim', + 'ipam' + ] + + standard_fields = [ + 'id', + 'name', + 'slug', + ] + + nested_fields = standard_fields + + def test_get_list(self, endpoint='/api/dcim/manufacturers/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/manufacturers/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + + +class DeviceTypeTest(APITestCase): + + fixtures = ['dcim', 'ipam'] + + standard_fields = [ + 'id', + 'manufacturer', + 'model', + 'slug', + 'u_height', + 'is_console_server', + 'is_pdu', + 'is_network_device' + ] + + nested_fields = [ + 'id', + 'manufacturer', + 'model', + 'slug' + ] + + def test_get_list(self, endpoint='/api/dcim/device-types/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + + def test_detail_list(self, endpoint='/api/dcim/device-types/1/'): + # TODO: details returns list view. + # response = self.client.get(endpoint) + # content = json.loads(response.content) + # self.assertEqual(response.status_code, status.HTTP_200_OK) + # self.assertEqual( + # sorted(content.keys()), + # sorted(self.standard_fields), + # ) + # self.assertEqual( + # sorted(content.get('manufacturer').keys()), + # sorted(ManufacturersTest.nested_fields), + # ) + pass + + +class DeviceRolesTest(APITestCase): + + fixtures = ['dcim', 'ipam'] + + standard_fields = ['id', 'name', 'slug', 'color'] + + nested_fields = ['id', 'name', 'slug'] + + def test_get_list(self, endpoint='/api/dcim/device-roles/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/device-roles/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + + +class PlatformsTest(APITestCase): + + fixtures = ['dcim', 'ipam'] + + standard_fields = ['id', 'name', 'slug', 'rpc_client'] + + nested_fields = ['id', 'name', 'slug'] + + def test_get_list(self, endpoint='/api/dcim/platforms/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/platforms/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + + +class DeviceTest(APITestCase): + + fixtures = ['dcim', 'ipam'] + + standard_fields = [ + 'id', + 'name', + 'display_name', + 'device_type', + 'device_role', + 'platform', + 'serial', + 'rack', + 'position', + 'face', + 'status', + 'primary_ip', + 'ro_snmp', + 'comments', + ] + + nested_fields = ['id', 'name'] + + def test_get_list(self, endpoint='/api/dcim/devices/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for device in content: + self.assertEqual( + sorted(device.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(device.get('device_type')), + sorted(DeviceTypeTest.nested_fields), + ) + self.assertEqual( + sorted(device.get('device_role')), + sorted(DeviceRolesTest.nested_fields), + ) + if device.get('platform'): + self.assertEqual( + sorted(device.get('platform')), + sorted(PlatformsTest.nested_fields), + ) + self.assertEqual( + sorted(device.get('rack')), + sorted(RackTest.nested_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/devices/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + + +class ConsoleServerPortsTest(APITestCase): + + fixtures = ['dcim', 'ipam'] + + standard_fields = ['id', 'device', 'name', 'connected_console'] + + nested_fields = ['id', 'device', 'name'] + + def test_get_list(self, endpoint='/api/dcim/devices/9/console-server-ports/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for console_port in content: + self.assertEqual( + sorted(console_port.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(console_port.get('device')), + sorted(DeviceTest.nested_fields), + ) + + +class ConsolePortsTest(APITestCase): + fixtures = ['dcim', 'ipam'] + + standard_fields = ['id', 'device', 'name', 'cs_port', 'connection_status'] + + nested_fields = ['id', 'device', 'name'] + + def test_get_list(self, endpoint='/api/dcim/devices/1/console-ports/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for console_port in content: + self.assertEqual( + sorted(console_port.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(console_port.get('device')), + sorted(DeviceTest.nested_fields), + ) + self.assertEqual( + sorted(console_port.get('cs_port')), + sorted(ConsoleServerPortsTest.nested_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/console-ports/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(content.get('device')), + sorted(DeviceTest.nested_fields), + ) + + +class PowerPortsTest(APITestCase): + fixtures = ['dcim', 'ipam'] + + standard_fields = ['id', 'device', 'name', 'power_outlet', 'connection_status'] + + nested_fields = ['id', 'device', 'name'] + + def test_get_list(self, endpoint='/api/dcim/devices/1/power-ports/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(i.get('device')), + sorted(DeviceTest.nested_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/power-ports/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(content.get('device')), + sorted(DeviceTest.nested_fields), + ) + + +class PowerOutletsTest(APITestCase): + fixtures = ['dcim', 'ipam'] + + standard_fields = ['id', 'device', 'name', 'connected_port'] + + nested_fields = ['id', 'device', 'name'] + + def test_get_list(self, endpoint='/api/dcim/devices/11/power-outlets/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(i.get('device')), + sorted(DeviceTest.nested_fields), + ) + + +class InterfaceTest(APITestCase): + fixtures = ['dcim', 'ipam', 'extras'] + + standard_fields = [ + 'id', + 'device', + 'name', + 'form_factor', + 'mgmt_only', + 'description', + 'is_connected' + ] + + nested_fields = ['id', 'device', 'name'] + + detail_fields = [ + 'id', + 'device', + 'name', + 'form_factor', + 'mgmt_only', + 'description', + 'is_connected', + 'connected_interface' + ] + + connection_fields = [ + 'id', + 'interface_a', + 'interface_b', + 'connection_status', + ] + + def test_get_list(self, endpoint='/api/dcim/devices/1/interfaces/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(self.standard_fields), + ) + self.assertEqual( + sorted(i.get('device')), + sorted(DeviceTest.nested_fields), + ) + + def test_get_detail(self, endpoint='/api/dcim/interfaces/1/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.detail_fields), + ) + self.assertEqual( + sorted(content.get('device')), + sorted(DeviceTest.nested_fields), + ) + + def test_get_graph_list(self, endpoint='/api/dcim/interfaces/1/graphs/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + for i in content: + self.assertEqual( + sorted(i.keys()), + sorted(SiteTest.graph_fields), + ) + + def test_get_interface_connections(self, endpoint='/api/dcim/interface-connections/4/'): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.connection_fields), + ) + + +class RelatedConnectionsTest(APITestCase): + + fixtures = ['dcim', 'ipam'] + + standard_fields = [ + 'device', + 'console-ports', + 'power-ports', + 'interfaces', + ] + + def test_get_list(self, endpoint=( + '/api/dcim/related-connections/' + '?peer-device=test1-edge1&peer-interface=xe-0/0/3')): + response = self.client.get(endpoint) + content = json.loads(response.content) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + sorted(content.keys()), + sorted(self.standard_fields), + ) diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py new file mode 100644 index 000000000..e4da0761e --- /dev/null +++ b/netbox/dcim/api/urls.py @@ -0,0 +1,66 @@ +from django.conf.urls import url + +from extras.models import GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE +from extras.api.views import GraphListView + +from .views import * + + +urlpatterns = [ + + # Sites + url(r'^sites/$', SiteListView.as_view(), name='site_list'), + url(r'^sites/(?P\d+)/$', SiteDetailView.as_view(), name='site_detail'), + url(r'^sites/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_SITE}, name='site_graphs'), + url(r'^sites/(?P\d+)/racks/$', RackListView.as_view(), name='site_racks'), + + # Rack groups + url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'), + url(r'^rack-groups/(?P\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'), + + # Racks + url(r'^racks/$', RackListView.as_view(), name='rack_list'), + url(r'^racks/(?P\d+)/$', RackDetailView.as_view(), name='rack_detail'), + url(r'^racks/(?P\d+)/rack-units/$', RackUnitListView.as_view(), name='rack_units'), + + # Manufacturers + url(r'^manufacturers/$', ManufacturerListView.as_view(), name='manufacturer_list'), + url(r'^manufacturers/(?P\d+)/$', ManufacturerDetailView.as_view(), name='manufacturer_detail'), + + # Device types + url(r'^device-types/$', DeviceTypeListView.as_view(), name='devicetype_list'), + url(r'^device-types/(?P\d+)/$', DeviceTypeDetailView.as_view(), name='devicetype_detail'), + + # Device roles + url(r'^device-roles/$', DeviceRoleListView.as_view(), name='devicerole_list'), + url(r'^device-roles/(?P\d+)/$', DeviceRoleDetailView.as_view(), name='devicerole_detail'), + + # Platforms + url(r'^platforms/$', PlatformListView.as_view(), name='platform_list'), + url(r'^platforms/(?P\d+)/$', PlatformDetailView.as_view(), name='platform_detail'), + + # Devices + url(r'^devices/$', DeviceListView.as_view(), name='device_list'), + url(r'^devices/(?P\d+)/$', DeviceDetailView.as_view(), name='device_detail'), + url(r'^devices/(?P\d+)/lldp-neighbors/$', LLDPNeighborsView.as_view(), name='device_lldp-neighbors'), + url(r'^devices/(?P\d+)/console-ports/$', ConsolePortListView.as_view(), name='device_consoleports'), + url(r'^devices/(?P\d+)/console-server-ports/$', ConsoleServerPortListView.as_view(), name='device_consoleserverports'), + url(r'^devices/(?P\d+)/power-ports/$', PowerPortListView.as_view(), name='device_powerports'), + url(r'^devices/(?P\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'), + url(r'^devices/(?P\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'), + + # Console ports + url(r'^console-ports/(?P\d+)/$', ConsolePortView.as_view(), name='consoleport'), + + # Power ports + url(r'^power-ports/(?P\d+)/$', PowerPortView.as_view(), name='powerport'), + + # Interfaces + url(r'^interfaces/(?P\d+)/$', InterfaceDetailView.as_view(), name='interface_detail'), + url(r'^interfaces/(?P\d+)/graphs/$', GraphListView.as_view(), {'type': GRAPH_TYPE_INTERFACE}, name='interface_graphs'), + url(r'^interface-connections/(?P\d+)/$', InterfaceConnectionView.as_view(), name='interfaceconnection'), + + # Miscellaneous + url(r'^related-connections/$', RelatedConnectionsView.as_view(), name='related_connections'), + +] diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py new file mode 100644 index 000000000..c2e1c5e16 --- /dev/null +++ b/netbox/dcim/api/views.py @@ -0,0 +1,438 @@ +from rest_framework import generics +from rest_framework.permissions import DjangoModelPermissionsOrAnonReadOnly +from rest_framework.response import Response +from rest_framework.settings import api_settings +from rest_framework.views import APIView + +from django.conf import settings +from django.http import Http404 +from django.shortcuts import get_object_or_404 + +from dcim.models import Site, Rack, RackGroup, Manufacturer, DeviceType, DeviceRole, Platform, Device, ConsolePort, \ + ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, IFACE_FF_VIRTUAL +from dcim.filters import RackGroupFilter, RackFilter, DeviceTypeFilter, DeviceFilter, InterfaceFilter +from .exceptions import MissingFilterException +from .serializers import SiteSerializer, RackGroupSerializer, RackSerializer, RackDetailSerializer, \ + ManufacturerSerializer, DeviceTypeSerializer, DeviceRoleSerializer, PlatformSerializer, DeviceSerializer, \ + DeviceNestedSerializer, ConsolePortSerializer, ConsoleServerPortSerializer, PowerPortSerializer, \ + PowerOutletSerializer, InterfaceSerializer, InterfaceDetailSerializer, InterfaceConnectionSerializer +from extras.api.renderers import BINDZoneRenderer +from utilities.api import ServiceUnavailable + + +# +# Sites +# + +class SiteListView(generics.ListAPIView): + """ + List all sites + """ + queryset = Site.objects.all() + serializer_class = SiteSerializer + + +class SiteDetailView(generics.RetrieveAPIView): + """ + Retrieve a single site + """ + queryset = Site.objects.all() + serializer_class = SiteSerializer + + +# +# Rack groups +# + +class RackGroupListView(generics.ListAPIView): + """ + List all rack groups + """ + queryset = RackGroup.objects.all() + serializer_class = RackGroupSerializer + filter_class = RackGroupFilter + + +class RackGroupDetailView(generics.RetrieveAPIView): + """ + Retrieve a single rack group + """ + queryset = RackGroup.objects.all() + serializer_class = RackGroupSerializer + + +# +# Racks +# + +class RackListView(generics.ListAPIView): + """ + List racks (filterable) + """ + queryset = Rack.objects.select_related('site') + serializer_class = RackSerializer + filter_class = RackFilter + + +class RackDetailView(generics.RetrieveAPIView): + """ + Retrieve a single rack + """ + queryset = Rack.objects.select_related('site') + serializer_class = RackDetailSerializer + + +# +# Rack units +# + +class RackUnitListView(APIView): + """ + List rack units (by rack) + """ + + def get(self, request, pk): + + rack = get_object_or_404(Rack, pk=pk) + face = request.GET.get('face', 0) + elevation = rack.get_rack_units(face) + + # Serialize Devices within the rack elevation + for u in elevation: + if u['device']: + u['device'] = DeviceNestedSerializer(instance=u['device']).data + + return Response(elevation) + + +# +# Manufacturers +# + +class ManufacturerListView(generics.ListAPIView): + """ + List all hardware manufacturers + """ + queryset = Manufacturer.objects.all() + serializer_class = ManufacturerSerializer + + +class ManufacturerDetailView(generics.RetrieveAPIView): + """ + Retrieve a single hardware manufacturers + """ + queryset = Manufacturer.objects.all() + serializer_class = ManufacturerSerializer + + +# +# Device Types +# + +class DeviceTypeListView(generics.ListAPIView): + """ + List device types (filterable) + """ + queryset = DeviceType.objects.select_related('manufacturer') + serializer_class = DeviceTypeSerializer + filter_class = DeviceTypeFilter + + +class DeviceTypeDetailView(generics.ListAPIView): + """ + Retrieve a single device type + """ + queryset = DeviceType.objects.select_related('manufacturer') + serializer_class = DeviceTypeSerializer + + +# +# Device roles +# + +class DeviceRoleListView(generics.ListAPIView): + """ + List all device roles + """ + queryset = DeviceRole.objects.all() + serializer_class = DeviceRoleSerializer + + +class DeviceRoleDetailView(generics.RetrieveAPIView): + """ + Retrieve a single device role + """ + queryset = DeviceRole.objects.all() + serializer_class = DeviceRoleSerializer + + +# +# Platforms +# + +class PlatformListView(generics.ListAPIView): + """ + List all platforms + """ + queryset = Platform.objects.all() + serializer_class = PlatformSerializer + + +class PlatformDetailView(generics.RetrieveAPIView): + """ + Retrieve a single platform + """ + queryset = Platform.objects.all() + serializer_class = PlatformSerializer + + +# +# Devices +# + +class DeviceListView(generics.ListAPIView): + """ + List devices (filterable) + """ + queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'platform', 'rack__site')\ + .prefetch_related('primary_ip__nat_outside') + serializer_class = DeviceSerializer + filter_class = DeviceFilter + renderer_classes = api_settings.DEFAULT_RENDERER_CLASSES + [BINDZoneRenderer] + + +class DeviceDetailView(generics.RetrieveAPIView): + """ + Retrieve a single device + """ + queryset = Device.objects.all() + serializer_class = DeviceSerializer + + +# +# Console ports +# + +class ConsolePortListView(generics.ListAPIView): + """ + List console ports (by device) + """ + serializer_class = ConsolePortSerializer + + def get_queryset(self): + + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return ConsolePort.objects.filter(device=device).select_related('cs_port') + + +class ConsolePortView(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + serializer_class = ConsolePortSerializer + queryset = ConsolePort.objects.all() + + +# +# Console server ports +# + +class ConsoleServerPortListView(generics.ListAPIView): + """ + List console server ports (by device) + """ + serializer_class = ConsoleServerPortSerializer + + def get_queryset(self): + + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + + +# +# Power ports +# + +class PowerPortListView(generics.ListAPIView): + """ + List power ports (by device) + """ + serializer_class = PowerPortSerializer + + def get_queryset(self): + + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return PowerPort.objects.filter(device=device).select_related('power_outlet') + + +class PowerPortView(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + serializer_class = PowerPortSerializer + queryset = PowerPort.objects.all() + + +# +# Power outlets +# + +class PowerOutletListView(generics.ListAPIView): + """ + List power outlets (by device) + """ + serializer_class = PowerOutletSerializer + + def get_queryset(self): + + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return PowerOutlet.objects.filter(device=device).select_related('connected_port') + + +# +# Interfaces +# + +class InterfaceListView(generics.ListAPIView): + """ + List interfaces (by device) + """ + serializer_class = InterfaceSerializer + filter_class = InterfaceFilter + + def get_queryset(self): + + device = get_object_or_404(Device, pk=self.kwargs['pk']) + queryset = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b') + + # Filter by type (physical or virtual) + iface_type = self.request.query_params.get('type') + if iface_type == 'physical': + queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL) + elif iface_type == 'virtual': + queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL) + elif iface_type is not None: + queryset = queryset.empty() + + return queryset + + +class InterfaceDetailView(generics.RetrieveAPIView): + """ + Retrieve a single interface + """ + queryset = Interface.objects.select_related('device') + serializer_class = InterfaceDetailSerializer + + +class InterfaceConnectionView(generics.RetrieveUpdateDestroyAPIView): + permission_classes = [DjangoModelPermissionsOrAnonReadOnly] + serializer_class = InterfaceConnectionSerializer + queryset = InterfaceConnection.objects.all() + + +# +# Live queries +# + +class LLDPNeighborsView(APIView): + """ + Retrieve live LLDP neighbors of a device + """ + + def get(self, request, pk): + + device = get_object_or_404(Device, pk=pk) + if not device.primary_ip: + raise ServiceUnavailable(detail="No IP configured for this device.") + hostname = str(device.primary_ip.address.ip) + + RPC = device.get_rpc_client() + if not RPC: + raise ServiceUnavailable(detail="No RPC client available for this platform ({}).".format(device.platform)) + + # Connect to device and retrieve inventory info + try: + with RPC(device, username=settings.NETBOX_USERNAME, password=settings.NETBOX_PASSWORD) as rpc_client: + lldp_neighbors = rpc_client.get_lldp_neighbors() + except: + raise ServiceUnavailable(detail="Error connecting to the remote device.") + + return Response(lldp_neighbors) + + +# +# Miscellaneous +# + +class RelatedConnectionsView(APIView): + """ + Retrieve all connections related to a given console/power/interface connection + """ + + def get(self, request): + + peer_device = request.GET.get('peer-device') + peer_interface = request.GET.get('peer-interface') + + # Search by interface + if peer_device and peer_interface: + + # Determine local interface from peer interface's connection + try: + peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface) + except Interface.DoesNotExist: + raise Http404() + local_iface = peer_iface.get_connected_interface() + if local_iface: + device = local_iface.device + else: + return Response() + + else: + raise MissingFilterException(detail='Must specify search parameters (peer-device and peer-interface).') + + # Initialize response skeleton + response = dict() + response['device'] = DeviceSerializer(device).data + response['console-ports'] = [] + response['power-ports'] = [] + response['interfaces'] = [] + + # Build console connections + console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device') + for cp in console_ports: + cp_info = dict() + cp_info['name'] = cp.name + if cp.cs_port: + cp_info['console-server'] = cp.cs_port.device.name + cp_info['port'] = cp.cs_port.name + else: + cp_info['console-server'] = None + cp_info['port'] = None + response['console-ports'].append(cp_info) + + # Build power connections + power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device') + for pp in power_ports: + pp_info = dict() + pp_info['name'] = pp.name + if pp.power_outlet: + pp_info['pdu'] = pp.power_outlet.device.name + pp_info['outlet'] = pp.power_outlet.name + else: + pp_info['pdu'] = None + pp_info['outlet'] = None + response['power-ports'].append(pp_info) + + # Built interface connections + interfaces = Interface.objects.filter(device=device) + for iface in interfaces: + iface_info = dict() + iface_info['name'] = iface.name + peer_interface = iface.get_connected_interface() + if peer_interface: + iface_info['device'] = peer_interface.device.name + iface_info['interface'] = peer_interface.name + else: + iface_info['device'] = None + iface_info['interface'] = None + response['interfaces'].append(iface_info) + + return Response(response) \ No newline at end of file diff --git a/netbox/dcim/apps.py b/netbox/dcim/apps.py new file mode 100644 index 000000000..8dfb6e3e6 --- /dev/null +++ b/netbox/dcim/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IPAMConfig(AppConfig): + name = "dcim" + verbose_name = "DCIM" diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py new file mode 100644 index 000000000..fda225ade --- /dev/null +++ b/netbox/dcim/filters.py @@ -0,0 +1,317 @@ +import django_filters + +from django.db.models import Q + +from .models import Site, RackGroup, Rack, Manufacturer, DeviceType, DeviceRole, Device, ConsolePort, \ + ConsoleServerPort, Platform, PowerPort, PowerOutlet, Interface, InterfaceConnection + + +class RackGroupFilter(django_filters.FilterSet): + site_id = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + + class Meta: + model = RackGroup + fields = ['site_id', 'site'] + + +class RackFilter(django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + group_id = django_filters.ModelMultipleChoiceFilter( + name='group', + queryset=RackGroup.objects.all(), + label='Group (ID)', + ) + group = django_filters.ModelMultipleChoiceFilter( + name='group', + queryset=RackGroup.objects.all(), + to_field_name='slug', + label='Group (slug)', + ) + + class Meta: + model = Rack + fields = ['q', 'site_id', 'site', 'u_height'] + + def search(self, queryset, value): + value = value.strip() + return queryset.filter( + Q(name__icontains=value) | + Q(facility_id__icontains=value) + ) + + +class DeviceTypeFilter(django_filters.FilterSet): + manufacturer_id = django_filters.ModelChoiceFilter( + name='manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelChoiceFilter( + name='manufacturer', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + + class Meta: + model = DeviceType + fields = ['manufacturer_id', 'manufacturer', 'model', 'u_height', 'is_console_server', 'is_pdu', + 'is_network_device'] + + +class DeviceFilter(django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + name='rack__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='rack__site', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) + rack_id = django_filters.ModelMultipleChoiceFilter( + name='rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='device_role', + queryset=DeviceRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='device_role', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + device_type = django_filters.ModelMultipleChoiceFilter( + name='device_type', + queryset=DeviceType.objects.all(), + label='Device type (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer', + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + name='device_type__manufacturer', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label='Manufacturer (slug)', + ) + model = django_filters.ModelMultipleChoiceFilter( + name='device_type', + queryset=DeviceType.objects.all(), + to_field_name='slug', + label='Device model (slug)', + ) + platform_id = django_filters.ModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + label='Platform (ID)', + ) + platform = django_filters.ModelMultipleChoiceFilter( + name='platform', + queryset=Platform.objects.all(), + to_field_name='slug', + label='Platform (slug)', + ) + is_console_server = django_filters.BooleanFilter( + name='device_type__is_console_server', + label='Is a console server', + ) + is_pdu = django_filters.BooleanFilter( + name='device_type__is_pdu', + label='Is a PDU', + ) + is_network_device = django_filters.BooleanFilter( + name='device_type__is_network_device', + label='Is a network device', + ) + + class Meta: + model = Device + fields = ['q', 'name', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type', 'manufacturer_id', + 'manufacturer', 'model', 'platform_id', 'platform', 'is_console_server', 'is_pdu', + 'is_network_device'] + + def search(self, queryset, value): + value = value.strip() + return queryset.filter( + Q(name__icontains=value) | + Q(serial__icontains=value) | + Q(modules__serial__icontains=value) + ).distinct() + + +class ConsolePortFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = ConsolePort + fields = ['device_id', 'device', 'name'] + + +class ConsoleServerPortFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = ConsoleServerPort + fields = ['device_id', 'device', 'name'] + + +class PowerPortFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = PowerPort + fields = ['device_id', 'device', 'name'] + + +class PowerOutletFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = PowerOutlet + fields = ['device_id', 'device', 'name'] + + +class InterfaceFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = Interface + fields = ['device_id', 'device', 'name'] + + +class ConsoleConnectionFilter(django_filters.FilterSet): + site = django_filters.MethodFilter( + action='filter_site', + label='Site (slug)', + ) + + class Meta: + model = ConsoleServerPort + + def filter_site(self, queryset, value): + value = value.strip() + if not value: + return queryset + return queryset.filter(cs_port__device__rack__site__slug=value) + + +class PowerConnectionFilter(django_filters.FilterSet): + site = django_filters.MethodFilter( + action='filter_site', + label='Site (slug)', + ) + + class Meta: + model = PowerOutlet + + def filter_site(self, queryset, value): + value = value.strip() + if not value: + return queryset + return queryset.filter(power_outlet__device__rack__site__slug=value) + + +class InterfaceConnectionFilter(django_filters.FilterSet): + site = django_filters.MethodFilter( + action='filter_site', + label='Site (slug)', + ) + + class Meta: + model = InterfaceConnection + + def filter_site(self, queryset, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(interface_a__device__rack__site__slug=value) | + Q(interface_b__device__rack__site__slug=value) + ) diff --git a/netbox/dcim/fixtures/dcim.yaml b/netbox/dcim/fixtures/dcim.yaml new file mode 100644 index 000000000..76c2f90ec --- /dev/null +++ b/netbox/dcim/fixtures/dcim.yaml @@ -0,0 +1,1794 @@ +- model: dcim.site + pk: 1 + fields: {name: TEST1, slug: test1, facility: Test Facility, asn: 65535, physical_address: "555 + Test Ave.\r\nTest, NY 55555", shipping_address: ''} +- model: dcim.rack + pk: 1 + fields: {name: A1R1, facility_id: T23A01, site: 1, u_height: 42, comments: ''} +- model: dcim.rack + pk: 2 + fields: {name: A1R2, facility_id: T24A01, site: 1, u_height: 42, comments: ''} +- model: dcim.manufacturer + pk: 1 + fields: {name: Juniper, slug: juniper} +- model: dcim.manufacturer + pk: 2 + fields: {name: Opengear, slug: opengear} +- model: dcim.manufacturer + pk: 3 + fields: {name: ServerTech, slug: servertech} +- model: dcim.devicetype + pk: 1 + fields: {manufacturer: 1, model: MX960, slug: mx960, u_height: 16, is_full_depth: true, + is_console_server: false, is_pdu: false, is_network_device: true} +- model: dcim.devicetype + pk: 2 + fields: {manufacturer: 1, model: EX9214, slug: ex9214, u_height: 16, is_full_depth: true, + is_console_server: false, is_pdu: false, is_network_device: true} +- model: dcim.devicetype + pk: 3 + fields: {manufacturer: 1, model: QFX5100-24Q, slug: qfx5100-24q, u_height: 1, is_full_depth: true, + is_console_server: false, is_pdu: false, is_network_device: true} +- model: dcim.devicetype + pk: 4 + fields: {manufacturer: 1, model: QFX5100-48S, slug: qfx5100-48s, u_height: 1, is_full_depth: true, + is_console_server: false, is_pdu: false, is_network_device: true} +- model: dcim.devicetype + pk: 5 + fields: {manufacturer: 2, model: CM4148, slug: cm4148, u_height: 1, is_full_depth: true, + is_console_server: true, is_pdu: false, is_network_device: false} +- model: dcim.devicetype + pk: 6 + fields: {manufacturer: 3, model: CWG-24VYM415C9, slug: cwg-24vym415c9, u_height: 0, + is_full_depth: false, is_console_server: false, is_pdu: true, is_network_device: false} +- model: dcim.consoleporttemplate + pk: 1 + fields: {device_type: 1, name: Console (RE0)} +- model: dcim.consoleporttemplate + pk: 2 + fields: {device_type: 1, name: Console (RE1)} +- model: dcim.consoleporttemplate + pk: 3 + fields: {device_type: 2, name: Console (RE0)} +- model: dcim.consoleporttemplate + pk: 4 + fields: {device_type: 2, name: Console (RE1)} +- model: dcim.consoleporttemplate + pk: 5 + fields: {device_type: 3, name: Console} +- model: dcim.consoleporttemplate + pk: 6 + fields: {device_type: 5, name: Console} +- model: dcim.consoleporttemplate + pk: 7 + fields: {device_type: 6, name: Serial} +- model: dcim.consoleserverporttemplate + pk: 1 + fields: {device_type: 3, name: Console} +- model: dcim.consoleserverporttemplate + pk: 3 + fields: {device_type: 4, name: Console} +- model: dcim.consoleserverporttemplate + pk: 4 + fields: {device_type: 5, name: Port 1} +- model: dcim.consoleserverporttemplate + pk: 5 + fields: {device_type: 5, name: Port 2} +- model: dcim.consoleserverporttemplate + pk: 6 + fields: {device_type: 5, name: Port 3} +- model: dcim.consoleserverporttemplate + pk: 7 + fields: {device_type: 5, name: Port 4} +- model: dcim.consoleserverporttemplate + pk: 8 + fields: {device_type: 5, name: Port 5} +- model: dcim.consoleserverporttemplate + pk: 9 + fields: {device_type: 5, name: Port 6} +- model: dcim.consoleserverporttemplate + pk: 10 + fields: {device_type: 5, name: Port 7} +- model: dcim.consoleserverporttemplate + pk: 11 + fields: {device_type: 5, name: Port 8} +- model: dcim.consoleserverporttemplate + pk: 12 + fields: {device_type: 5, name: Port 9} +- model: dcim.consoleserverporttemplate + pk: 13 + fields: {device_type: 5, name: Port 10} +- model: dcim.consoleserverporttemplate + pk: 14 + fields: {device_type: 5, name: Port 11} +- model: dcim.consoleserverporttemplate + pk: 15 + fields: {device_type: 5, name: Port 12} +- model: dcim.consoleserverporttemplate + pk: 16 + fields: {device_type: 5, name: Port 13} +- model: dcim.consoleserverporttemplate + pk: 17 + fields: {device_type: 5, name: Port 14} +- model: dcim.consoleserverporttemplate + pk: 18 + fields: {device_type: 5, name: Port 15} +- model: dcim.consoleserverporttemplate + pk: 19 + fields: {device_type: 5, name: Port 16} +- model: dcim.consoleserverporttemplate + pk: 20 + fields: {device_type: 5, name: Port 17} +- model: dcim.consoleserverporttemplate + pk: 21 + fields: {device_type: 5, name: Port 18} +- model: dcim.consoleserverporttemplate + pk: 22 + fields: {device_type: 5, name: Port 19} +- model: dcim.consoleserverporttemplate + pk: 23 + fields: {device_type: 5, name: Port 20} +- model: dcim.consoleserverporttemplate + pk: 24 + fields: {device_type: 5, name: Port 21} +- model: dcim.consoleserverporttemplate + pk: 25 + fields: {device_type: 5, name: Port 22} +- model: dcim.consoleserverporttemplate + pk: 26 + fields: {device_type: 5, name: Port 23} +- model: dcim.consoleserverporttemplate + pk: 27 + fields: {device_type: 5, name: Port 24} +- model: dcim.consoleserverporttemplate + pk: 28 + fields: {device_type: 5, name: Port 25} +- model: dcim.consoleserverporttemplate + pk: 29 + fields: {device_type: 5, name: Port 26} +- model: dcim.consoleserverporttemplate + pk: 30 + fields: {device_type: 5, name: Port 27} +- model: dcim.consoleserverporttemplate + pk: 31 + fields: {device_type: 5, name: Port 28} +- model: dcim.consoleserverporttemplate + pk: 32 + fields: {device_type: 5, name: Port 29} +- model: dcim.consoleserverporttemplate + pk: 33 + fields: {device_type: 5, name: Port 30} +- model: dcim.consoleserverporttemplate + pk: 34 + fields: {device_type: 5, name: Port 31} +- model: dcim.consoleserverporttemplate + pk: 35 + fields: {device_type: 5, name: Port 32} +- model: dcim.consoleserverporttemplate + pk: 36 + fields: {device_type: 5, name: Port 33} +- model: dcim.consoleserverporttemplate + pk: 37 + fields: {device_type: 5, name: Port 34} +- model: dcim.consoleserverporttemplate + pk: 38 + fields: {device_type: 5, name: Port 35} +- model: dcim.consoleserverporttemplate + pk: 39 + fields: {device_type: 5, name: Port 36} +- model: dcim.consoleserverporttemplate + pk: 40 + fields: {device_type: 5, name: Port 37} +- model: dcim.consoleserverporttemplate + pk: 41 + fields: {device_type: 5, name: Port 38} +- model: dcim.consoleserverporttemplate + pk: 42 + fields: {device_type: 5, name: Port 39} +- model: dcim.consoleserverporttemplate + pk: 43 + fields: {device_type: 5, name: Port 40} +- model: dcim.consoleserverporttemplate + pk: 44 + fields: {device_type: 5, name: Port 41} +- model: dcim.consoleserverporttemplate + pk: 45 + fields: {device_type: 5, name: Port 42} +- model: dcim.consoleserverporttemplate + pk: 46 + fields: {device_type: 5, name: Port 43} +- model: dcim.consoleserverporttemplate + pk: 47 + fields: {device_type: 5, name: Port 44} +- model: dcim.consoleserverporttemplate + pk: 48 + fields: {device_type: 5, name: Port 45} +- model: dcim.consoleserverporttemplate + pk: 49 + fields: {device_type: 5, name: Port 46} +- model: dcim.consoleserverporttemplate + pk: 50 + fields: {device_type: 5, name: Port 47} +- model: dcim.consoleserverporttemplate + pk: 51 + fields: {device_type: 5, name: Port 48} +- model: dcim.powerporttemplate + pk: 1 + fields: {device_type: 1, name: PEM0} +- model: dcim.powerporttemplate + pk: 2 + fields: {device_type: 1, name: PEM1} +- model: dcim.powerporttemplate + pk: 3 + fields: {device_type: 1, name: PEM2} +- model: dcim.powerporttemplate + pk: 4 + fields: {device_type: 1, name: PEM3} +- model: dcim.powerporttemplate + pk: 5 + fields: {device_type: 2, name: PEM0} +- model: dcim.powerporttemplate + pk: 6 + fields: {device_type: 2, name: PEM1} +- model: dcim.powerporttemplate + pk: 7 + fields: {device_type: 2, name: PEM2} +- model: dcim.powerporttemplate + pk: 8 + fields: {device_type: 2, name: PEM3} +- model: dcim.powerporttemplate + pk: 9 + fields: {device_type: 4, name: PSU0} +- model: dcim.powerporttemplate + pk: 11 + fields: {device_type: 3, name: PSU0} +- model: dcim.powerporttemplate + pk: 12 + fields: {device_type: 3, name: PSU1} +- model: dcim.powerporttemplate + pk: 13 + fields: {device_type: 4, name: PSU1} +- model: dcim.powerporttemplate + pk: 14 + fields: {device_type: 5, name: PSU} +- model: dcim.poweroutlettemplate + pk: 4 + fields: {device_type: 6, name: AA1} +- model: dcim.poweroutlettemplate + pk: 5 + fields: {device_type: 6, name: AA2} +- model: dcim.poweroutlettemplate + pk: 6 + fields: {device_type: 6, name: AA3} +- model: dcim.poweroutlettemplate + pk: 7 + fields: {device_type: 6, name: AA4} +- model: dcim.poweroutlettemplate + pk: 8 + fields: {device_type: 6, name: AA5} +- model: dcim.poweroutlettemplate + pk: 9 + fields: {device_type: 6, name: AA6} +- model: dcim.poweroutlettemplate + pk: 10 + fields: {device_type: 6, name: AA7} +- model: dcim.poweroutlettemplate + pk: 11 + fields: {device_type: 6, name: AA8} +- model: dcim.poweroutlettemplate + pk: 12 + fields: {device_type: 6, name: AB1} +- model: dcim.poweroutlettemplate + pk: 13 + fields: {device_type: 6, name: AB2} +- model: dcim.poweroutlettemplate + pk: 14 + fields: {device_type: 6, name: AB3} +- model: dcim.poweroutlettemplate + pk: 15 + fields: {device_type: 6, name: AB4} +- model: dcim.poweroutlettemplate + pk: 16 + fields: {device_type: 6, name: AB5} +- model: dcim.poweroutlettemplate + pk: 17 + fields: {device_type: 6, name: AB6} +- model: dcim.poweroutlettemplate + pk: 18 + fields: {device_type: 6, name: AB7} +- model: dcim.poweroutlettemplate + pk: 19 + fields: {device_type: 6, name: AB8} +- model: dcim.poweroutlettemplate + pk: 20 + fields: {device_type: 6, name: AC1} +- model: dcim.poweroutlettemplate + pk: 21 + fields: {device_type: 6, name: AC2} +- model: dcim.poweroutlettemplate + pk: 22 + fields: {device_type: 6, name: AC3} +- model: dcim.poweroutlettemplate + pk: 23 + fields: {device_type: 6, name: AC4} +- model: dcim.poweroutlettemplate + pk: 24 + fields: {device_type: 6, name: AC5} +- model: dcim.poweroutlettemplate + pk: 25 + fields: {device_type: 6, name: AC6} +- model: dcim.poweroutlettemplate + pk: 26 + fields: {device_type: 6, name: AC7} +- model: dcim.poweroutlettemplate + pk: 27 + fields: {device_type: 6, name: AC8} +- model: dcim.interfacetemplate + pk: 1 + fields: {device_type: 1, name: fxp0 (RE0), form_factor: 1000, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 2 + fields: {device_type: 1, name: fxp0 (RE1), form_factor: 800, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 3 + fields: {device_type: 1, name: lo0, form_factor: 0, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 4 + fields: {device_type: 2, name: fxp0 (RE0), form_factor: 1000, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 5 + fields: {device_type: 2, name: fxp0 (RE1), form_factor: 1000, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 6 + fields: {device_type: 2, name: lo0, form_factor: 0, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 7 + fields: {device_type: 3, name: em0, form_factor: 800, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 8 + fields: {device_type: 3, name: et-0/0/0, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 9 + fields: {device_type: 3, name: et-0/0/1, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 10 + fields: {device_type: 3, name: et-0/0/2, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 11 + fields: {device_type: 3, name: et-0/0/3, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 12 + fields: {device_type: 3, name: et-0/0/4, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 13 + fields: {device_type: 3, name: et-0/0/5, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 14 + fields: {device_type: 3, name: et-0/0/6, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 15 + fields: {device_type: 3, name: et-0/0/7, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 16 + fields: {device_type: 3, name: et-0/0/8, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 17 + fields: {device_type: 3, name: et-0/0/9, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 18 + fields: {device_type: 3, name: et-0/0/10, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 19 + fields: {device_type: 3, name: et-0/0/11, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 20 + fields: {device_type: 3, name: et-0/0/12, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 21 + fields: {device_type: 3, name: et-0/0/13, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 22 + fields: {device_type: 3, name: et-0/0/14, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 23 + fields: {device_type: 3, name: et-0/0/15, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 24 + fields: {device_type: 3, name: et-0/0/16, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 25 + fields: {device_type: 3, name: et-0/0/17, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 26 + fields: {device_type: 3, name: et-0/0/18, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 27 + fields: {device_type: 3, name: et-0/0/19, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 28 + fields: {device_type: 3, name: et-0/0/20, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 29 + fields: {device_type: 3, name: et-0/0/21, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 30 + fields: {device_type: 3, name: et-0/0/22, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 31 + fields: {device_type: 3, name: et-0/1/0, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 32 + fields: {device_type: 3, name: et-0/1/1, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 33 + fields: {device_type: 3, name: et-0/1/2, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 34 + fields: {device_type: 3, name: et-0/1/3, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 35 + fields: {device_type: 3, name: et-0/2/0, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 36 + fields: {device_type: 3, name: et-0/2/1, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 37 + fields: {device_type: 3, name: et-0/2/2, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 38 + fields: {device_type: 3, name: et-0/2/3, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 138 + fields: {device_type: 4, name: em0, form_factor: 1000, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 139 + fields: {device_type: 4, name: xe-0/0/0, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 140 + fields: {device_type: 4, name: xe-0/0/1, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 141 + fields: {device_type: 4, name: xe-0/0/2, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 142 + fields: {device_type: 4, name: xe-0/0/3, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 143 + fields: {device_type: 4, name: xe-0/0/4, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 144 + fields: {device_type: 4, name: xe-0/0/5, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 145 + fields: {device_type: 4, name: xe-0/0/6, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 146 + fields: {device_type: 4, name: xe-0/0/7, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 147 + fields: {device_type: 4, name: xe-0/0/8, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 148 + fields: {device_type: 4, name: xe-0/0/9, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 149 + fields: {device_type: 4, name: xe-0/0/10, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 150 + fields: {device_type: 4, name: xe-0/0/11, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 151 + fields: {device_type: 4, name: xe-0/0/12, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 152 + fields: {device_type: 4, name: xe-0/0/13, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 153 + fields: {device_type: 4, name: xe-0/0/14, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 154 + fields: {device_type: 4, name: xe-0/0/15, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 155 + fields: {device_type: 4, name: xe-0/0/16, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 156 + fields: {device_type: 4, name: xe-0/0/17, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 157 + fields: {device_type: 4, name: xe-0/0/18, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 158 + fields: {device_type: 4, name: xe-0/0/19, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 159 + fields: {device_type: 4, name: xe-0/0/20, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 160 + fields: {device_type: 4, name: xe-0/0/21, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 161 + fields: {device_type: 4, name: xe-0/0/22, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 162 + fields: {device_type: 4, name: xe-0/0/23, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 163 + fields: {device_type: 4, name: xe-0/0/24, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 164 + fields: {device_type: 4, name: xe-0/0/25, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 165 + fields: {device_type: 4, name: xe-0/0/26, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 166 + fields: {device_type: 4, name: xe-0/0/27, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 167 + fields: {device_type: 4, name: xe-0/0/28, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 168 + fields: {device_type: 4, name: xe-0/0/29, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 169 + fields: {device_type: 4, name: xe-0/0/30, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 170 + fields: {device_type: 4, name: xe-0/0/31, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 171 + fields: {device_type: 4, name: xe-0/0/32, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 172 + fields: {device_type: 4, name: xe-0/0/33, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 173 + fields: {device_type: 4, name: xe-0/0/34, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 174 + fields: {device_type: 4, name: xe-0/0/35, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 175 + fields: {device_type: 4, name: xe-0/0/36, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 176 + fields: {device_type: 4, name: xe-0/0/37, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 177 + fields: {device_type: 4, name: xe-0/0/38, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 178 + fields: {device_type: 4, name: xe-0/0/39, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 179 + fields: {device_type: 4, name: xe-0/0/40, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 180 + fields: {device_type: 4, name: xe-0/0/41, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 181 + fields: {device_type: 4, name: xe-0/0/42, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 182 + fields: {device_type: 4, name: xe-0/0/43, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 183 + fields: {device_type: 4, name: xe-0/0/44, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 184 + fields: {device_type: 4, name: xe-0/0/45, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 185 + fields: {device_type: 4, name: xe-0/0/46, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 186 + fields: {device_type: 4, name: xe-0/0/47, form_factor: 1200, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 187 + fields: {device_type: 4, name: et-0/0/48, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 188 + fields: {device_type: 4, name: et-0/0/49, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 189 + fields: {device_type: 4, name: et-0/0/50, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 190 + fields: {device_type: 4, name: et-0/0/51, form_factor: 1400, mgmt_only: false} +- model: dcim.interfacetemplate + pk: 191 + fields: {device_type: 5, name: eth0, form_factor: 1000, mgmt_only: true} +- model: dcim.interfacetemplate + pk: 192 + fields: {device_type: 6, name: Net, form_factor: 800, mgmt_only: true} +- model: dcim.devicerole + pk: 1 + fields: {name: Router, slug: router, color: purple} +- model: dcim.devicerole + pk: 2 + fields: {name: Spine Switch, slug: spine-switch, color: green} +- model: dcim.devicerole + pk: 3 + fields: {name: Core Switch, slug: core-switch, color: red} +- model: dcim.devicerole + pk: 4 + fields: {name: Leaf Switch, slug: leaf-switch, color: teal} +- model: dcim.devicerole + pk: 5 + fields: {name: OOB Switch, slug: oob-switch, color: purple} +- model: dcim.devicerole + pk: 6 + fields: {name: PDU, slug: pdu, color: yellow} +- model: dcim.platform + pk: 1 + fields: {name: Juniper Junos, slug: juniper-junos, rpc_client: juniper-junos} +- model: dcim.platform + pk: 2 + fields: {name: Opengear, slug: opengear, rpc_client: opengear} +- model: dcim.device + pk: 1 + fields: {device_type: 1, device_role: 1, platform: 1, name: test1-edge1, serial: '5555555555', + rack: 1, position: 1, face: 0, status: true, primary_ip: 1, ro_snmp: TEST} +- model: dcim.device + pk: 2 + fields: {device_type: 2, device_role: 3, platform: 1, name: test1-core1, serial: '', + rack: 1, position: 17, face: 0, status: true, primary_ip: 5, ro_snmp: ''} +- model: dcim.device + pk: 3 + fields: {device_type: 3, device_role: 2, platform: 1, name: test1-spine1, serial: '', + rack: 1, position: 33, face: 0, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.device + pk: 4 + fields: {device_type: 4, device_role: 4, platform: 1, name: test1-leaf1, serial: '', + rack: 1, position: 34, face: 0, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.device + pk: 5 + fields: {device_type: 4, device_role: 4, platform: 1, name: test1-leaf2, serial: '9823478293748', + rack: 2, position: 34, face: 0, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.device + pk: 6 + fields: {device_type: 3, device_role: 2, platform: 1, name: test1-spine2, serial: '45649818158', + rack: 2, position: 33, face: 0, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.device + pk: 7 + fields: {device_type: 1, device_role: 1, platform: 1, name: test1-edge2, serial: '7567356345', + rack: 2, position: 1, face: 0, status: true, primary_ip: 3, ro_snmp: ''} +- model: dcim.device + pk: 8 + fields: {device_type: 2, device_role: 3, platform: 1, name: test1-core2, serial: '67856734534', + rack: 2, position: 17, face: 0, status: true, primary_ip: 19, ro_snmp: ''} +- model: dcim.device + pk: 9 + fields: {device_type: 5, device_role: 5, platform: 2, name: test1-oob1, serial: '98273942938', + rack: 1, position: 42, face: 0, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.device + pk: 11 + fields: {device_type: 6, device_role: 6, platform: null, name: test1-pdu1, serial: '', + rack: 1, position: null, face: null, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.device + pk: 12 + fields: {device_type: 6, device_role: 6, platform: null, name: test1-pdu2, serial: '', + rack: 2, position: null, face: null, status: true, primary_ip: null, ro_snmp: ''} +- model: dcim.consoleport + pk: 1 + fields: {device: 1, name: Console (RE0), cs_port: 27, connection_status: true} +- model: dcim.consoleport + pk: 2 + fields: {device: 1, name: Console (RE1), cs_port: 38, connection_status: true} +- model: dcim.consoleport + pk: 3 + fields: {device: 2, name: Console (RE0), cs_port: 5, connection_status: true} +- model: dcim.consoleport + pk: 4 + fields: {device: 2, name: Console (RE1), cs_port: 16, connection_status: true} +- model: dcim.consoleport + pk: 5 + fields: {device: 3, name: Console, cs_port: 49, connection_status: true} +- model: dcim.consoleport + pk: 6 + fields: {device: 4, name: Console, cs_port: 48, connection_status: true} +- model: dcim.consoleport + pk: 7 + fields: {device: 5, name: Console, cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 8 + fields: {device: 6, name: Console, cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 9 + fields: {device: 7, name: Console (RE0), cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 10 + fields: {device: 7, name: Console (RE1), cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 11 + fields: {device: 8, name: Console (RE0), cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 12 + fields: {device: 8, name: Console (RE1), cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 13 + fields: {device: 9, name: Console, cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 15 + fields: {device: 11, name: Serial, cs_port: null, connection_status: true} +- model: dcim.consoleport + pk: 16 + fields: {device: 12, name: Serial, cs_port: null, connection_status: true} +- model: dcim.consoleserverport + pk: 5 + fields: {device: 9, name: Port 1} +- model: dcim.consoleserverport + pk: 6 + fields: {device: 9, name: Port 10} +- model: dcim.consoleserverport + pk: 7 + fields: {device: 9, name: Port 11} +- model: dcim.consoleserverport + pk: 8 + fields: {device: 9, name: Port 12} +- model: dcim.consoleserverport + pk: 9 + fields: {device: 9, name: Port 13} +- model: dcim.consoleserverport + pk: 10 + fields: {device: 9, name: Port 14} +- model: dcim.consoleserverport + pk: 11 + fields: {device: 9, name: Port 15} +- model: dcim.consoleserverport + pk: 12 + fields: {device: 9, name: Port 16} +- model: dcim.consoleserverport + pk: 13 + fields: {device: 9, name: Port 17} +- model: dcim.consoleserverport + pk: 14 + fields: {device: 9, name: Port 18} +- model: dcim.consoleserverport + pk: 15 + fields: {device: 9, name: Port 19} +- model: dcim.consoleserverport + pk: 16 + fields: {device: 9, name: Port 2} +- model: dcim.consoleserverport + pk: 17 + fields: {device: 9, name: Port 20} +- model: dcim.consoleserverport + pk: 18 + fields: {device: 9, name: Port 21} +- model: dcim.consoleserverport + pk: 19 + fields: {device: 9, name: Port 22} +- model: dcim.consoleserverport + pk: 20 + fields: {device: 9, name: Port 23} +- model: dcim.consoleserverport + pk: 21 + fields: {device: 9, name: Port 24} +- model: dcim.consoleserverport + pk: 22 + fields: {device: 9, name: Port 25} +- model: dcim.consoleserverport + pk: 23 + fields: {device: 9, name: Port 26} +- model: dcim.consoleserverport + pk: 24 + fields: {device: 9, name: Port 27} +- model: dcim.consoleserverport + pk: 25 + fields: {device: 9, name: Port 28} +- model: dcim.consoleserverport + pk: 26 + fields: {device: 9, name: Port 29} +- model: dcim.consoleserverport + pk: 27 + fields: {device: 9, name: Port 3} +- model: dcim.consoleserverport + pk: 28 + fields: {device: 9, name: Port 30} +- model: dcim.consoleserverport + pk: 29 + fields: {device: 9, name: Port 31} +- model: dcim.consoleserverport + pk: 30 + fields: {device: 9, name: Port 32} +- model: dcim.consoleserverport + pk: 31 + fields: {device: 9, name: Port 33} +- model: dcim.consoleserverport + pk: 32 + fields: {device: 9, name: Port 34} +- model: dcim.consoleserverport + pk: 33 + fields: {device: 9, name: Port 35} +- model: dcim.consoleserverport + pk: 34 + fields: {device: 9, name: Port 36} +- model: dcim.consoleserverport + pk: 35 + fields: {device: 9, name: Port 37} +- model: dcim.consoleserverport + pk: 36 + fields: {device: 9, name: Port 38} +- model: dcim.consoleserverport + pk: 37 + fields: {device: 9, name: Port 39} +- model: dcim.consoleserverport + pk: 38 + fields: {device: 9, name: Port 4} +- model: dcim.consoleserverport + pk: 39 + fields: {device: 9, name: Port 40} +- model: dcim.consoleserverport + pk: 40 + fields: {device: 9, name: Port 41} +- model: dcim.consoleserverport + pk: 41 + fields: {device: 9, name: Port 42} +- model: dcim.consoleserverport + pk: 42 + fields: {device: 9, name: Port 43} +- model: dcim.consoleserverport + pk: 43 + fields: {device: 9, name: Port 44} +- model: dcim.consoleserverport + pk: 44 + fields: {device: 9, name: Port 45} +- model: dcim.consoleserverport + pk: 45 + fields: {device: 9, name: Port 46} +- model: dcim.consoleserverport + pk: 46 + fields: {device: 9, name: Port 47} +- model: dcim.consoleserverport + pk: 47 + fields: {device: 9, name: Port 48} +- model: dcim.consoleserverport + pk: 48 + fields: {device: 9, name: Port 5} +- model: dcim.consoleserverport + pk: 49 + fields: {device: 9, name: Port 6} +- model: dcim.consoleserverport + pk: 50 + fields: {device: 9, name: Port 7} +- model: dcim.consoleserverport + pk: 51 + fields: {device: 9, name: Port 8} +- model: dcim.consoleserverport + pk: 52 + fields: {device: 9, name: Port 9} +- model: dcim.powerport + pk: 1 + fields: {device: 1, name: PEM0, power_outlet: 25, connection_status: true} +- model: dcim.powerport + pk: 2 + fields: {device: 1, name: PEM1, power_outlet: 49, connection_status: true} +- model: dcim.powerport + pk: 3 + fields: {device: 1, name: PEM2, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 4 + fields: {device: 1, name: PEM3, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 5 + fields: {device: 2, name: PEM0, power_outlet: 26, connection_status: true} +- model: dcim.powerport + pk: 6 + fields: {device: 2, name: PEM1, power_outlet: 50, connection_status: true} +- model: dcim.powerport + pk: 7 + fields: {device: 2, name: PEM2, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 8 + fields: {device: 2, name: PEM3, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 9 + fields: {device: 4, name: PSU0, power_outlet: 28, connection_status: true} +- model: dcim.powerport + pk: 10 + fields: {device: 4, name: PSU1, power_outlet: 52, connection_status: true} +- model: dcim.powerport + pk: 11 + fields: {device: 5, name: PSU0, power_outlet: 56, connection_status: true} +- model: dcim.powerport + pk: 12 + fields: {device: 5, name: PSU1, power_outlet: 32, connection_status: true} +- model: dcim.powerport + pk: 13 + fields: {device: 3, name: PSU0, power_outlet: 27, connection_status: true} +- model: dcim.powerport + pk: 14 + fields: {device: 3, name: PSU1, power_outlet: 51, connection_status: true} +- model: dcim.powerport + pk: 15 + fields: {device: 7, name: PEM0, power_outlet: 53, connection_status: true} +- model: dcim.powerport + pk: 16 + fields: {device: 7, name: PEM1, power_outlet: 29, connection_status: true} +- model: dcim.powerport + pk: 17 + fields: {device: 7, name: PEM2, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 18 + fields: {device: 7, name: PEM3, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 19 + fields: {device: 8, name: PEM0, power_outlet: 54, connection_status: true} +- model: dcim.powerport + pk: 20 + fields: {device: 8, name: PEM1, power_outlet: 30, connection_status: true} +- model: dcim.powerport + pk: 21 + fields: {device: 8, name: PEM2, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 22 + fields: {device: 8, name: PEM3, power_outlet: null, connection_status: true} +- model: dcim.powerport + pk: 23 + fields: {device: 6, name: PSU0, power_outlet: 55, connection_status: true} +- model: dcim.powerport + pk: 24 + fields: {device: 6, name: PSU1, power_outlet: 31, connection_status: true} +- model: dcim.powerport + pk: 25 + fields: {device: 9, name: PSU, power_outlet: null, connection_status: true} +- model: dcim.poweroutlet + pk: 25 + fields: {device: 11, name: AA1} +- model: dcim.poweroutlet + pk: 26 + fields: {device: 11, name: AA2} +- model: dcim.poweroutlet + pk: 27 + fields: {device: 11, name: AA3} +- model: dcim.poweroutlet + pk: 28 + fields: {device: 11, name: AA4} +- model: dcim.poweroutlet + pk: 29 + fields: {device: 11, name: AA5} +- model: dcim.poweroutlet + pk: 30 + fields: {device: 11, name: AA6} +- model: dcim.poweroutlet + pk: 31 + fields: {device: 11, name: AA7} +- model: dcim.poweroutlet + pk: 32 + fields: {device: 11, name: AA8} +- model: dcim.poweroutlet + pk: 33 + fields: {device: 11, name: AB1} +- model: dcim.poweroutlet + pk: 34 + fields: {device: 11, name: AB2} +- model: dcim.poweroutlet + pk: 35 + fields: {device: 11, name: AB3} +- model: dcim.poweroutlet + pk: 36 + fields: {device: 11, name: AB4} +- model: dcim.poweroutlet + pk: 37 + fields: {device: 11, name: AB5} +- model: dcim.poweroutlet + pk: 38 + fields: {device: 11, name: AB6} +- model: dcim.poweroutlet + pk: 39 + fields: {device: 11, name: AB7} +- model: dcim.poweroutlet + pk: 40 + fields: {device: 11, name: AB8} +- model: dcim.poweroutlet + pk: 41 + fields: {device: 11, name: AC1} +- model: dcim.poweroutlet + pk: 42 + fields: {device: 11, name: AC2} +- model: dcim.poweroutlet + pk: 43 + fields: {device: 11, name: AC3} +- model: dcim.poweroutlet + pk: 44 + fields: {device: 11, name: AC4} +- model: dcim.poweroutlet + pk: 45 + fields: {device: 11, name: AC5} +- model: dcim.poweroutlet + pk: 46 + fields: {device: 11, name: AC6} +- model: dcim.poweroutlet + pk: 47 + fields: {device: 11, name: AC7} +- model: dcim.poweroutlet + pk: 48 + fields: {device: 11, name: AC8} +- model: dcim.poweroutlet + pk: 49 + fields: {device: 12, name: AA1} +- model: dcim.poweroutlet + pk: 50 + fields: {device: 12, name: AA2} +- model: dcim.poweroutlet + pk: 51 + fields: {device: 12, name: AA3} +- model: dcim.poweroutlet + pk: 52 + fields: {device: 12, name: AA4} +- model: dcim.poweroutlet + pk: 53 + fields: {device: 12, name: AA5} +- model: dcim.poweroutlet + pk: 54 + fields: {device: 12, name: AA6} +- model: dcim.poweroutlet + pk: 55 + fields: {device: 12, name: AA7} +- model: dcim.poweroutlet + pk: 56 + fields: {device: 12, name: AA8} +- model: dcim.poweroutlet + pk: 57 + fields: {device: 12, name: AB1} +- model: dcim.poweroutlet + pk: 58 + fields: {device: 12, name: AB2} +- model: dcim.poweroutlet + pk: 59 + fields: {device: 12, name: AB3} +- model: dcim.poweroutlet + pk: 60 + fields: {device: 12, name: AB4} +- model: dcim.poweroutlet + pk: 61 + fields: {device: 12, name: AB5} +- model: dcim.poweroutlet + pk: 62 + fields: {device: 12, name: AB6} +- model: dcim.poweroutlet + pk: 63 + fields: {device: 12, name: AB7} +- model: dcim.poweroutlet + pk: 64 + fields: {device: 12, name: AB8} +- model: dcim.poweroutlet + pk: 65 + fields: {device: 12, name: AC1} +- model: dcim.poweroutlet + pk: 66 + fields: {device: 12, name: AC2} +- model: dcim.poweroutlet + pk: 67 + fields: {device: 12, name: AC3} +- model: dcim.poweroutlet + pk: 68 + fields: {device: 12, name: AC4} +- model: dcim.poweroutlet + pk: 69 + fields: {device: 12, name: AC5} +- model: dcim.poweroutlet + pk: 70 + fields: {device: 12, name: AC6} +- model: dcim.poweroutlet + pk: 71 + fields: {device: 12, name: AC7} +- model: dcim.poweroutlet + pk: 72 + fields: {device: 12, name: AC8} +- model: dcim.interface + pk: 1 + fields: {device: 1, name: fxp0 (RE0), form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 2 + fields: {device: 1, name: fxp0 (RE1), form_factor: 800, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 3 + fields: {device: 1, name: lo0, form_factor: 0, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 4 + fields: {device: 1, name: xe-0/0/0, form_factor: 1200, mgmt_only: false, description: TEST} +- model: dcim.interface + pk: 5 + fields: {device: 1, name: xe-0/0/1, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 6 + fields: {device: 1, name: xe-0/0/2, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 7 + fields: {device: 1, name: xe-0/0/3, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 8 + fields: {device: 1, name: xe-0/0/4, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 9 + fields: {device: 1, name: xe-0/0/5, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 10 + fields: {device: 2, name: fxp0 (RE0), form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 11 + fields: {device: 2, name: fxp0 (RE1), form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 12 + fields: {device: 2, name: lo0, form_factor: 0, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 13 + fields: {device: 3, name: em0, form_factor: 800, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 14 + fields: {device: 3, name: et-0/0/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 15 + fields: {device: 3, name: et-0/0/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 16 + fields: {device: 3, name: et-0/0/10, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 17 + fields: {device: 3, name: et-0/0/11, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 18 + fields: {device: 3, name: et-0/0/12, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 19 + fields: {device: 3, name: et-0/0/13, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 20 + fields: {device: 3, name: et-0/0/14, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 21 + fields: {device: 3, name: et-0/0/15, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 22 + fields: {device: 3, name: et-0/0/16, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 23 + fields: {device: 3, name: et-0/0/17, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 24 + fields: {device: 3, name: et-0/0/18, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 25 + fields: {device: 3, name: et-0/0/19, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 26 + fields: {device: 3, name: et-0/0/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 27 + fields: {device: 3, name: et-0/0/20, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 28 + fields: {device: 3, name: et-0/0/21, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 29 + fields: {device: 3, name: et-0/0/22, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 30 + fields: {device: 3, name: et-0/0/3, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 31 + fields: {device: 3, name: et-0/0/4, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 32 + fields: {device: 3, name: et-0/0/5, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 33 + fields: {device: 3, name: et-0/0/6, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 34 + fields: {device: 3, name: et-0/0/7, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 35 + fields: {device: 3, name: et-0/0/8, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 36 + fields: {device: 3, name: et-0/0/9, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 37 + fields: {device: 3, name: et-0/1/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 38 + fields: {device: 3, name: et-0/1/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 39 + fields: {device: 3, name: et-0/1/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 40 + fields: {device: 3, name: et-0/1/3, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 41 + fields: {device: 3, name: et-0/2/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 42 + fields: {device: 3, name: et-0/2/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 43 + fields: {device: 3, name: et-0/2/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 44 + fields: {device: 3, name: et-0/2/3, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 45 + fields: {device: 4, name: em0, form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 46 + fields: {device: 4, name: et-0/0/48, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 47 + fields: {device: 4, name: et-0/0/49, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 48 + fields: {device: 4, name: et-0/0/50, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 49 + fields: {device: 4, name: et-0/0/51, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 50 + fields: {device: 4, name: xe-0/0/0, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 51 + fields: {device: 4, name: xe-0/0/1, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 52 + fields: {device: 4, name: xe-0/0/10, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 53 + fields: {device: 4, name: xe-0/0/11, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 54 + fields: {device: 4, name: xe-0/0/12, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 55 + fields: {device: 4, name: xe-0/0/13, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 56 + fields: {device: 4, name: xe-0/0/14, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 57 + fields: {device: 4, name: xe-0/0/15, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 58 + fields: {device: 4, name: xe-0/0/16, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 59 + fields: {device: 4, name: xe-0/0/17, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 60 + fields: {device: 4, name: xe-0/0/18, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 61 + fields: {device: 4, name: xe-0/0/19, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 62 + fields: {device: 4, name: xe-0/0/2, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 63 + fields: {device: 4, name: xe-0/0/20, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 64 + fields: {device: 4, name: xe-0/0/21, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 65 + fields: {device: 4, name: xe-0/0/22, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 66 + fields: {device: 4, name: xe-0/0/23, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 67 + fields: {device: 4, name: xe-0/0/24, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 68 + fields: {device: 4, name: xe-0/0/25, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 69 + fields: {device: 4, name: xe-0/0/26, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 70 + fields: {device: 4, name: xe-0/0/27, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 71 + fields: {device: 4, name: xe-0/0/28, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 72 + fields: {device: 4, name: xe-0/0/29, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 73 + fields: {device: 4, name: xe-0/0/3, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 74 + fields: {device: 4, name: xe-0/0/30, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 75 + fields: {device: 4, name: xe-0/0/31, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 76 + fields: {device: 4, name: xe-0/0/32, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 77 + fields: {device: 4, name: xe-0/0/33, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 78 + fields: {device: 4, name: xe-0/0/34, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 79 + fields: {device: 4, name: xe-0/0/35, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 80 + fields: {device: 4, name: xe-0/0/36, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 81 + fields: {device: 4, name: xe-0/0/37, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 82 + fields: {device: 4, name: xe-0/0/38, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 83 + fields: {device: 4, name: xe-0/0/39, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 84 + fields: {device: 4, name: xe-0/0/4, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 85 + fields: {device: 4, name: xe-0/0/40, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 86 + fields: {device: 4, name: xe-0/0/41, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 87 + fields: {device: 4, name: xe-0/0/42, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 88 + fields: {device: 4, name: xe-0/0/43, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 89 + fields: {device: 4, name: xe-0/0/44, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 90 + fields: {device: 4, name: xe-0/0/45, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 91 + fields: {device: 4, name: xe-0/0/46, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 92 + fields: {device: 4, name: xe-0/0/47, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 93 + fields: {device: 4, name: xe-0/0/5, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 94 + fields: {device: 4, name: xe-0/0/6, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 95 + fields: {device: 4, name: xe-0/0/7, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 96 + fields: {device: 4, name: xe-0/0/8, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 97 + fields: {device: 4, name: xe-0/0/9, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 98 + fields: {device: 5, name: em0, form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 99 + fields: {device: 5, name: et-0/0/48, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 100 + fields: {device: 5, name: et-0/0/49, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 101 + fields: {device: 5, name: et-0/0/50, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 102 + fields: {device: 5, name: et-0/0/51, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 103 + fields: {device: 5, name: xe-0/0/0, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 104 + fields: {device: 5, name: xe-0/0/1, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 105 + fields: {device: 5, name: xe-0/0/10, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 106 + fields: {device: 5, name: xe-0/0/11, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 107 + fields: {device: 5, name: xe-0/0/12, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 108 + fields: {device: 5, name: xe-0/0/13, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 109 + fields: {device: 5, name: xe-0/0/14, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 110 + fields: {device: 5, name: xe-0/0/15, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 111 + fields: {device: 5, name: xe-0/0/16, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 112 + fields: {device: 5, name: xe-0/0/17, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 113 + fields: {device: 5, name: xe-0/0/18, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 114 + fields: {device: 5, name: xe-0/0/19, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 115 + fields: {device: 5, name: xe-0/0/2, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 116 + fields: {device: 5, name: xe-0/0/20, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 117 + fields: {device: 5, name: xe-0/0/21, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 118 + fields: {device: 5, name: xe-0/0/22, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 119 + fields: {device: 5, name: xe-0/0/23, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 120 + fields: {device: 5, name: xe-0/0/24, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 121 + fields: {device: 5, name: xe-0/0/25, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 122 + fields: {device: 5, name: xe-0/0/26, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 123 + fields: {device: 5, name: xe-0/0/27, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 124 + fields: {device: 5, name: xe-0/0/28, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 125 + fields: {device: 5, name: xe-0/0/29, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 126 + fields: {device: 5, name: xe-0/0/3, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 127 + fields: {device: 5, name: xe-0/0/30, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 128 + fields: {device: 5, name: xe-0/0/31, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 129 + fields: {device: 5, name: xe-0/0/32, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 130 + fields: {device: 5, name: xe-0/0/33, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 131 + fields: {device: 5, name: xe-0/0/34, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 132 + fields: {device: 5, name: xe-0/0/35, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 133 + fields: {device: 5, name: xe-0/0/36, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 134 + fields: {device: 5, name: xe-0/0/37, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 135 + fields: {device: 5, name: xe-0/0/38, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 136 + fields: {device: 5, name: xe-0/0/39, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 137 + fields: {device: 5, name: xe-0/0/4, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 138 + fields: {device: 5, name: xe-0/0/40, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 139 + fields: {device: 5, name: xe-0/0/41, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 140 + fields: {device: 5, name: xe-0/0/42, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 141 + fields: {device: 5, name: xe-0/0/43, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 142 + fields: {device: 5, name: xe-0/0/44, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 143 + fields: {device: 5, name: xe-0/0/45, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 144 + fields: {device: 5, name: xe-0/0/46, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 145 + fields: {device: 5, name: xe-0/0/47, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 146 + fields: {device: 5, name: xe-0/0/5, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 147 + fields: {device: 5, name: xe-0/0/6, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 148 + fields: {device: 5, name: xe-0/0/7, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 149 + fields: {device: 5, name: xe-0/0/8, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 150 + fields: {device: 5, name: xe-0/0/9, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 151 + fields: {device: 6, name: em0, form_factor: 800, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 152 + fields: {device: 6, name: et-0/0/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 153 + fields: {device: 6, name: et-0/0/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 154 + fields: {device: 6, name: et-0/0/10, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 155 + fields: {device: 6, name: et-0/0/11, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 156 + fields: {device: 6, name: et-0/0/12, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 157 + fields: {device: 6, name: et-0/0/13, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 158 + fields: {device: 6, name: et-0/0/14, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 159 + fields: {device: 6, name: et-0/0/15, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 160 + fields: {device: 6, name: et-0/0/16, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 161 + fields: {device: 6, name: et-0/0/17, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 162 + fields: {device: 6, name: et-0/0/18, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 163 + fields: {device: 6, name: et-0/0/19, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 164 + fields: {device: 6, name: et-0/0/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 165 + fields: {device: 6, name: et-0/0/20, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 166 + fields: {device: 6, name: et-0/0/21, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 167 + fields: {device: 6, name: et-0/0/22, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 168 + fields: {device: 6, name: et-0/0/3, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 169 + fields: {device: 6, name: et-0/0/4, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 170 + fields: {device: 6, name: et-0/0/5, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 171 + fields: {device: 6, name: et-0/0/6, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 172 + fields: {device: 6, name: et-0/0/7, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 173 + fields: {device: 6, name: et-0/0/8, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 174 + fields: {device: 6, name: et-0/0/9, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 175 + fields: {device: 6, name: et-0/1/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 176 + fields: {device: 6, name: et-0/1/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 177 + fields: {device: 6, name: et-0/1/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 178 + fields: {device: 6, name: et-0/1/3, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 179 + fields: {device: 6, name: et-0/2/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 180 + fields: {device: 6, name: et-0/2/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 181 + fields: {device: 6, name: et-0/2/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 182 + fields: {device: 6, name: et-0/2/3, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 183 + fields: {device: 7, name: fxp0 (RE0), form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 184 + fields: {device: 7, name: fxp0 (RE1), form_factor: 800, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 185 + fields: {device: 7, name: lo0, form_factor: 0, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 186 + fields: {device: 8, name: fxp0 (RE0), form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 187 + fields: {device: 8, name: fxp0 (RE1), form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 188 + fields: {device: 8, name: lo0, form_factor: 0, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 189 + fields: {device: 2, name: et-0/0/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 190 + fields: {device: 2, name: et-0/0/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 191 + fields: {device: 2, name: et-0/0/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 192 + fields: {device: 2, name: et-0/1/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 193 + fields: {device: 2, name: et-0/1/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 194 + fields: {device: 2, name: et-0/1/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 195 + fields: {device: 8, name: et-0/0/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 196 + fields: {device: 8, name: et-0/0/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 197 + fields: {device: 8, name: et-0/0/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 198 + fields: {device: 8, name: et-0/1/0, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 199 + fields: {device: 8, name: et-0/1/1, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 200 + fields: {device: 8, name: et-0/1/2, form_factor: 1400, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 201 + fields: {device: 2, name: xe-0/0/0, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 202 + fields: {device: 2, name: xe-0/0/1, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 203 + fields: {device: 2, name: xe-0/0/2, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 204 + fields: {device: 2, name: xe-0/0/3, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 205 + fields: {device: 2, name: xe-0/0/4, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 206 + fields: {device: 2, name: xe-0/0/5, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 207 + fields: {device: 8, name: xe-0/0/0, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 208 + fields: {device: 8, name: xe-0/0/1, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 209 + fields: {device: 8, name: xe-0/0/2, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 210 + fields: {device: 8, name: xe-0/0/3, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 211 + fields: {device: 8, name: xe-0/0/4, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 212 + fields: {device: 8, name: xe-0/0/5, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 213 + fields: {device: 7, name: xe-0/0/0, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 214 + fields: {device: 7, name: xe-0/0/1, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 215 + fields: {device: 7, name: xe-0/0/2, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 216 + fields: {device: 7, name: xe-0/0/3, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 217 + fields: {device: 7, name: xe-0/0/4, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 218 + fields: {device: 7, name: xe-0/0/5, form_factor: 1200, mgmt_only: false, description: ''} +- model: dcim.interface + pk: 219 + fields: {device: 9, name: eth0, form_factor: 1000, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 221 + fields: {device: 11, name: Net, form_factor: 800, mgmt_only: true, description: ''} +- model: dcim.interface + pk: 222 + fields: {device: 12, name: Net, form_factor: 800, mgmt_only: true, description: ''} +- model: dcim.interfaceconnection + pk: 3 + fields: {interface_a: 99, interface_b: 15, connection_status: true} +- model: dcim.interfaceconnection + pk: 4 + fields: {interface_a: 100, interface_b: 153, connection_status: true} +- model: dcim.interfaceconnection + pk: 5 + fields: {interface_a: 46, interface_b: 14, connection_status: true} +- model: dcim.interfaceconnection + pk: 6 + fields: {interface_a: 47, interface_b: 152, connection_status: true} +- model: dcim.interfaceconnection + pk: 7 + fields: {interface_a: 91, interface_b: 144, connection_status: true} +- model: dcim.interfaceconnection + pk: 8 + fields: {interface_a: 92, interface_b: 145, connection_status: true} +- model: dcim.interfaceconnection + pk: 16 + fields: {interface_a: 189, interface_b: 37, connection_status: true} +- model: dcim.interfaceconnection + pk: 17 + fields: {interface_a: 192, interface_b: 175, connection_status: true} +- model: dcim.interfaceconnection + pk: 18 + fields: {interface_a: 195, interface_b: 41, connection_status: true} +- model: dcim.interfaceconnection + pk: 19 + fields: {interface_a: 198, interface_b: 179, connection_status: true} +- model: dcim.interfaceconnection + pk: 20 + fields: {interface_a: 191, interface_b: 197, connection_status: true} +- model: dcim.interfaceconnection + pk: 21 + fields: {interface_a: 194, interface_b: 200, connection_status: true} +- model: dcim.interfaceconnection + pk: 22 + fields: {interface_a: 9, interface_b: 218, connection_status: true} +- model: dcim.interfaceconnection + pk: 23 + fields: {interface_a: 8, interface_b: 206, connection_status: true} +- model: dcim.interfaceconnection + pk: 24 + fields: {interface_a: 7, interface_b: 212, connection_status: true} +- model: dcim.interfaceconnection + pk: 25 + fields: {interface_a: 217, interface_b: 205, connection_status: true} +- model: dcim.interfaceconnection + pk: 26 + fields: {interface_a: 216, interface_b: 211, connection_status: true} diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py new file mode 100644 index 000000000..4d6baae6e --- /dev/null +++ b/netbox/dcim/forms.py @@ -0,0 +1,953 @@ +import re + +from django import forms +from django.db.models import Count, Q + +from ipam.models import IPAddress +from utilities.forms import BootstrapMixin, SmallTextarea, SelectWithDisabled, ConfirmationForm, APISelect, \ + Livesearch, CSVDataField, CommentField, BulkImportForm, FlexibleModelChoiceField, ExpandableNameField + +from .models import Site, Rack, RackGroup, Device, Manufacturer, DeviceType, DeviceRole, Platform, ConsolePort, \ + ConsoleServerPort, PowerPort, PowerOutlet, Interface, InterfaceConnection, CONNECTION_STATUS_CHOICES, \ + CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, IFACE_FF_VIRTUAL, STATUS_CHOICES + + +BULK_STATUS_CHOICES = [ + ['', '---------'], +] +BULK_STATUS_CHOICES += STATUS_CHOICES + +DEVICE_BY_PK_RE = '{\d+\}' + + +def get_device_by_name_or_pk(name): + """ + Attempt to retrieve a device by either its name or primary key ('{pk}'). + """ + if re.match(DEVICE_BY_PK_RE, name): + pk = name.strip('{}') + device = Device.objects.get(pk=pk) + else: + device = Device.objects.get(name=name) + return device + + +# +# Sites +# + +class SiteForm(forms.ModelForm, BootstrapMixin): + comments = CommentField() + + class Meta: + model = Site + fields = ['name', 'slug', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments'] + widgets = { + 'physical_address': SmallTextarea(attrs={'rows': 3}), + 'shipping_address': SmallTextarea(attrs={'rows': 3}), + } + help_texts = { + 'name': "Full name of the site", + 'slug': "URL-friendly unique shorthand (e.g. 'nyc3' for NYC3)", + 'facility': "Data center provider and facility (e.g. Equinix NY7)", + 'asn': "BGP autonomous system number", + 'physical_address': "Physical location of the building (e.g. for GPS)", + 'shipping_address': "If different from the physical address" + } + + +class SiteFromCSVForm(forms.ModelForm): + + class Meta: + model = Site + fields = ['name', 'slug', 'facility', 'asn'] + + +class SiteImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=SiteFromCSVForm) + + +# +# Racks +# + +class RackForm(forms.ModelForm, BootstrapMixin): + group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False, label='Group', widget=APISelect( + api_url='/api/dcim/rack-groups/?site_id={{site}}', + )) + comments = CommentField() + + class Meta: + model = Rack + fields = ['site', 'group', 'name', 'facility_id', 'u_height', 'comments'] + help_texts = { + 'site': "The site at which the rack exists", + 'name': "Organizational rack name", + 'facility_id': "The unique rack ID assigned by the facility", + 'u_height': "Height in rack units", + } + widgets = { + 'site': forms.Select(attrs={'filter-for': 'group'}), + } + + def __init__(self, *args, **kwargs): + + super(RackForm, self).__init__(*args, **kwargs) + + # Limit rack group choices + if self.is_bound and self.data.get('site'): + self.fields['group'].queryset = RackGroup.objects.filter(site__pk=self.data['site']) + elif self.initial.get('site'): + self.fields['group'].queryset = RackGroup.objects.filter(site=self.initial['site']) + else: + self.fields['group'].choices = [] + + +class RackFromCSVForm(forms.ModelForm): + site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Site not found.'}) + group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Group not found.'}) + + class Meta: + model = Rack + fields = ['site', 'group', 'name', 'facility_id', 'u_height'] + + def clean(self): + + site = self.cleaned_data.get('site') + group = self.cleaned_data.get('group') + + # Validate device type + if site and group: + try: + self.instance.group = RackGroup.objects.get(site=site, name=group) + except RackGroup.DoesNotExist: + self.add_error('group', "Invalid rack group ({})".format(group)) + + +class RackImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=RackFromCSVForm) + + +class RackBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) + group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False) + u_height = forms.IntegerField(required=False, label='Height (U)') + comments = CommentField() + + +class RackBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) + + +def rack_site_choices(): + site_choices = Site.objects.annotate(rack_count=Count('racks')) + return [(s.slug, '{} ({})'.format(s.name, s.rack_count)) for s in site_choices] + + +def rack_group_choices(): + group_choices = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) + return [(g.slug, '{} > {} ({})'.format(g.site.name, g.name, g.rack_count)) for g in group_choices] + + +class RackFilterForm(forms.Form, BootstrapMixin): + site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + group = forms.MultipleChoiceField(required=False, choices=rack_group_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + + +# +# Devices +# + +class DeviceForm(forms.ModelForm, BootstrapMixin): + site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), widget=APISelect( + api_url='/api/dcim/racks/?site_id={{site}}', + display_field='display_name', + attrs={'filter-for': 'position'} + )) + position = forms.TypedChoiceField(required=False, empty_value=None, widget=APISelect( + api_url='/api/dcim/racks/{{rack}}/rack-units/?face={{face}}', + disabled_indicator='device', + )) + manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), + widget=forms.Select(attrs={'filter-for': 'device_type'})) + device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), label='Model', widget=APISelect( + api_url='/api/dcim/device-types/?manufacturer_id={{manufacturer}}', + display_field='model' + )) + comments = CommentField() + + class Meta: + model = Device + fields = ['name', 'device_role', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status', + 'platform', 'primary_ip', 'ro_snmp', 'comments'] + help_texts = { + 'device_role': "The function this device serves", + 'serial': "Chassis serial number", + 'ro_snmp': "Read-only SNMP string", + } + widgets = { + 'face': forms.Select(attrs={'filter-for': 'position'}), + 'manufacturer': forms.Select(attrs={'filter-for': 'device_type'}), + } + + def __init__(self, *args, **kwargs): + + super(DeviceForm, self).__init__(*args, **kwargs) + + if self.instance.pk: + + # Initialize helper selections + self.initial['site'] = self.instance.rack.site + self.initial['manufacturer'] = self.instance.device_type.manufacturer + + # Compile list of IPs assigned to this device + primary_ip_choices = [] + interface_ips = IPAddress.objects.filter(interface__device=self.instance) + primary_ip_choices += [(ip.id, '{} ({})'.format(ip.address, ip.interface)) for ip in interface_ips] + nat_ips = IPAddress.objects.filter(nat_inside__interface__device=self.instance)\ + .select_related('nat_inside__interface') + primary_ip_choices += [(ip.id, '{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips] + self.fields['primary_ip'].choices = [(None, '---------')] + primary_ip_choices + + else: + + # An object that doesn't exist yet can't have any IPs assigned to it + self.fields['primary_ip'].choices = [] + self.fields['primary_ip'].widget.attrs['readonly'] = True + + # Limit rack choices + if self.is_bound: + self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) + elif self.initial.get('site'): + self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) + else: + self.fields['rack'].choices = [] + + # Rack position + try: + if self.is_bound and self.data.get('rack') and self.data.get('face') is not None: + position_choices = Rack.objects.get(pk=self.data['rack']).get_rack_units(face=self.data.get('face')) + elif self.initial.get('rack') and self.initial.get('face') is not None: + position_choices = Rack.objects.get(pk=self.initial['rack']).get_rack_units(face=self.initial.get('face')) + else: + position_choices = [] + except Rack.DoesNotExist: + position_choices = [] + self.fields['position'].choices = [('', '---------')] + [ + (p['id'], { + 'label': p['name'], + 'disabled': bool(p['device'] and p['id'] != self.initial.get('position')), + }) for p in position_choices + ] + + # Limit device_type choices + if self.is_bound: + self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer__pk=self.data['manufacturer'])\ + .select_related('manufacturer') + elif self.initial.get('manufacturer'): + self.fields['device_type'].queryset = DeviceType.objects.filter(manufacturer=self.initial['manufacturer'])\ + .select_related('manufacturer') + else: + self.fields['device_type'].choices = [] + + +class DeviceFromCSVForm(forms.ModelForm): + device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid device role.'}) + manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid manufacturer.'}) + model_name = forms.CharField() + platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Invalid platform.'}) + site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', error_messages={ + 'invalid_choice': 'Invalid site name.', + }) + rack_name = forms.CharField() + face = forms.ChoiceField(choices=[('front', 'Front'), ('rear', 'Rear')]) + + class Meta: + model = Device + fields = ['name', 'device_role', 'manufacturer', 'model_name', 'platform', 'serial', 'site', 'rack_name', + 'position', 'face'] + + def clean(self): + + manufacturer = self.cleaned_data.get('manufacturer') + model_name = self.cleaned_data.get('model_name') + site = self.cleaned_data.get('site') + rack_name = self.cleaned_data.get('rack_name') + + # Validate device type + if manufacturer and model_name: + try: + self.instance.device_type = DeviceType.objects.get(manufacturer=manufacturer, model=model_name) + except DeviceType.DoesNotExist: + self.add_error('model_name', "Invalid device type ({})".format(model_name)) + + # Validate rack + if site and rack_name: + try: + self.instance.rack = Rack.objects.get(site=site, name=rack_name) + except Rack.DoesNotExist: + self.add_error('rack_name', "Invalid rack ({})".format(rack_name)) + + def clean_face(self): + face = self.cleaned_data['face'] + if face.lower() == 'front': + return 0 + if face.lower() == 'rear': + return 1 + raise forms.ValidationError("Invalid rack face ({})".format(face)) + + +class DeviceImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=DeviceFromCSVForm) + + +class DeviceBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + device_type = forms.ModelChoiceField(queryset=DeviceType.objects.all(), required=False, label='Type') + device_role = forms.ModelChoiceField(queryset=DeviceRole.objects.all(), required=False, label='Role') + platform = forms.ModelChoiceField(queryset=Platform.objects.all(), required=False, label='Platform') + platform_delete = forms.BooleanField(required=False, label='Set platform to "none"') + status = forms.ChoiceField(choices=BULK_STATUS_CHOICES, required=False, initial='', label='Status') + serial = forms.CharField(max_length=50, required=False, label='Serial Number') + ro_snmp = forms.CharField(max_length=50, required=False, label='SNMP (RO)') + + +class DeviceBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +def device_site_choices(): + site_choices = Site.objects.annotate(device_count=Count('racks__devices')) + return [(s.slug, '{} ({})'.format(s.name, s.device_count)) for s in site_choices] + + +def device_role_choices(): + role_choices = DeviceRole.objects.annotate(device_count=Count('devices')) + return [(r.slug, '{} ({})'.format(r.name, r.device_count)) for r in role_choices] + + +def device_type_choices(): + type_choices = DeviceType.objects.select_related('manufacturer').annotate(device_count=Count('instances')) + return [(t.slug, '{} ({})'.format(t, t.device_count)) for t in type_choices] + + +def device_platform_choices(): + platform_choices = Platform.objects.annotate(device_count=Count('devices')) + return [(p.slug, '{} ({})'.format(p.name, p.device_count)) for p in platform_choices] + + +class DeviceFilterForm(forms.Form, BootstrapMixin): + site = forms.MultipleChoiceField(required=False, choices=device_site_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + role = forms.MultipleChoiceField(required=False, choices=device_role_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + type = forms.MultipleChoiceField(required=False, choices=device_type_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + platform = forms.MultipleChoiceField(required=False, choices=device_platform_choices) + + +# +# Console ports +# + +class ConsolePortForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = ConsolePort + fields = ['device', 'name'] + widgets = { + 'device': forms.HiddenInput(), + } + + +class ConsolePortCreateForm(forms.Form, BootstrapMixin): + name_pattern = ExpandableNameField(label='Name') + + +class ConsoleConnectionCSVForm(forms.Form): + console_server = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_console_server=True), + to_field_name='name', + error_messages={'invalid_choice': 'Console server not found'}) + cs_port = forms.CharField() + device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Device not found'}) + console_port = forms.CharField() + status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) + + def clean(self): + + # Validate console server port + if self.cleaned_data.get('console_server'): + try: + cs_port = ConsoleServerPort.objects.get(device=self.cleaned_data['console_server'], + name=self.cleaned_data['cs_port']) + if ConsolePort.objects.filter(cs_port=cs_port): + raise forms.ValidationError("Console server port is already occupied (by {} {})" + .format(cs_port.connected_console.device, cs_port.connected_console)) + except ConsoleServerPort.DoesNotExist: + raise forms.ValidationError("Invalid console server port ({} {})" + .format(self.cleaned_data['console_server'], self.cleaned_data['cs_port'])) + + # Validate console port + if self.cleaned_data.get('device'): + try: + console_port = ConsolePort.objects.get(device=self.cleaned_data['device'], + name=self.cleaned_data['console_port']) + if console_port.cs_port: + raise forms.ValidationError("Console port is already connected (to {} {})" + .format(console_port.cs_port.device, console_port.cs_port)) + except ConsolePort.DoesNotExist: + raise forms.ValidationError("Invalid console port ({} {})" + .format(self.cleaned_data['device'], self.cleaned_data['console_port'])) + + +class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=ConsoleConnectionCSVForm) + + def clean(self): + records = self.cleaned_data.get('csv') + if not records: + return + + connection_list = [] + + for i, record in enumerate(records, start=1): + form = self.fields['csv'].csv_form(data=record) + if form.is_valid(): + console_port = ConsolePort.objects.get(device=form.cleaned_data['device'], + name=form.cleaned_data['console_port']) + console_port.cs_port = ConsoleServerPort.objects.get(device=form.cleaned_data['console_server'], + name=form.cleaned_data['cs_port']) + if form.cleaned_data['status'] == 'planned': + console_port.connection_status = CONNECTION_STATUS_PLANNED + else: + console_port.connection_status = CONNECTION_STATUS_CONNECTED + connection_list.append(console_port) + else: + for field, errors in form.errors.items(): + for e in errors: + self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + + self.cleaned_data['csv'] = connection_list + + +class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin): + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, + widget=forms.Select(attrs={'filter-for': 'console_server'})) + console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False, + widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_console_server=True', + attrs={'filter-for': 'cs_port'})) + livesearch = forms.CharField(required=False, label='Console Server', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='console_server') + ) + cs_port = forms.ModelChoiceField(queryset=ConsoleServerPort.objects.all(), label='Port', + widget=APISelect(api_url='/api/dcim/devices/{{console_server}}/console-server-ports/', + disabled_indicator='connected_console')) + + class Meta: + model = ConsolePort + fields = ['rack', 'console_server', 'livesearch', 'cs_port', 'connection_status'] + labels = { + 'cs_port': 'Port', + 'connection_status': 'Status', + } + + def __init__(self, *args, **kwargs): + + super(ConsolePortConnectionForm, self).__init__(*args, **kwargs) + + if not self.instance.pk: + raise RuntimeError("ConsolePortConnectionForm must be initialized with an existing ConsolePort instance.") + + self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site) + self.fields['cs_port'].required = True + self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES + + # Initialize console server choices + if self.is_bound and self.data.get('rack'): + self.fields['console_server'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_console_server=True) + elif self.initial.get('rack'): + self.fields['console_server'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_console_server=True) + else: + self.fields['console_server'].choices = [] + + # Initialize CS port choices + if self.is_bound: + self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(device__pk=self.data['console_server']) + elif self.initial.get('console_server', None): + self.fields['cs_port'].queryset = ConsoleServerPort.objects.filter(device__pk=self.initial['console_server']) + else: + self.fields['cs_port'].choices = [] + + +# +# Console server ports +# + +class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = ConsoleServerPort + fields = ['device', 'name'] + widgets = { + 'device': forms.HiddenInput(), + } + + +class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin): + name_pattern = ExpandableNameField(label='Name') + + +class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin): + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, + widget=forms.Select(attrs={'filter-for': 'device'})) + device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, + widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', + attrs={'filter-for': 'port'})) + livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='device') + ) + port = forms.ModelChoiceField(queryset=ConsolePort.objects.all(), label='Port', + widget=APISelect(api_url='/api/dcim/devices/{{device}}/console-ports/', + disabled_indicator='cs_port')) + connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status', + widget=forms.Select(choices=CONNECTION_STATUS_CHOICES)) + + class Meta: + fields = ['rack', 'device', 'livesearch', 'port', 'connection_status'] + labels = { + 'connection_status': 'Status', + } + + def __init__(self, consoleserverport, *args, **kwargs): + + super(ConsoleServerPortConnectionForm, self).__init__(*args, **kwargs) + + self.fields['rack'].queryset = Rack.objects.filter(site=consoleserverport.device.rack.site) + + # Initialize device choices + if self.is_bound and self.data.get('rack'): + self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack']) + elif self.initial.get('rack', None): + self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) + else: + self.fields['device'].choices = [] + + # Initialize port choices + if self.is_bound: + self.fields['port'].queryset = ConsolePort.objects.filter(device__pk=self.data['device']) + elif self.initial.get('device', None): + self.fields['port'].queryset = ConsolePort.objects.filter(device_pk=self.initial['device']) + else: + self.fields['port'].choices = [] + + +# +# Power ports +# + +class PowerPortForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = PowerPort + fields = ['device', 'name'] + widgets = { + 'device': forms.HiddenInput(), + } + + +class PowerPortCreateForm(forms.Form, BootstrapMixin): + name_pattern = ExpandableNameField(label='Name') + + +class PowerConnectionCSVForm(forms.Form): + pdu = FlexibleModelChoiceField(queryset=Device.objects.filter(device_type__is_pdu=True), to_field_name='name', + error_messages={'invalid_choice': 'PDU not found.'}) + power_outlet = forms.CharField() + device = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Device not found'}) + power_port = forms.CharField() + status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) + + def clean(self): + + # Validate power outlet + if self.cleaned_data.get('pdu'): + try: + power_outlet = PowerOutlet.objects.get(device=self.cleaned_data['pdu'], + name=self.cleaned_data['power_outlet']) + if PowerPort.objects.filter(power_outlet=power_outlet): + raise forms.ValidationError("Power outlet is already occupied (by {} {})" + .format(power_outlet.connected_console.device, + power_outlet.connected_console)) + except PowerOutlet.DoesNotExist: + raise forms.ValidationError("Invalid PDU port ({} {})" + .format(self.cleaned_data['pdu'], self.cleaned_data['power_outlet'])) + + # Validate power port + if self.cleaned_data.get('device'): + try: + power_port = PowerPort.objects.get(device=self.cleaned_data['device'], + name=self.cleaned_data['power_port']) + if power_port.power_outlet: + raise forms.ValidationError("Power port is already connected (to {} {})" + .format(power_port.power_outlet.device, power_port.power_outlet)) + except PowerPort.DoesNotExist: + raise forms.ValidationError("Invalid power port ({} {})" + .format(self.cleaned_data['device'], self.cleaned_data['power_port'])) + + +class PowerConnectionImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=PowerConnectionCSVForm) + + def clean(self): + records = self.cleaned_data.get('csv') + if not records: + return + + connection_list = [] + + for i, record in enumerate(records, start=1): + form = self.fields['csv'].csv_form(data=record) + if form.is_valid(): + power_port = PowerPort.objects.get(device=form.cleaned_data['device'], + name=form.cleaned_data['power_port']) + power_port.cs_port = PowerOutlet.objects.get(device=form.cleaned_data['pdu'], + name=form.cleaned_data['power_outlet']) + if form.cleaned_data['status'] == 'planned': + power_port.connection_status = CONNECTION_STATUS_PLANNED + else: + power_port.connection_status = CONNECTION_STATUS_CONNECTED + connection_list.append(power_port) + else: + for field, errors in form.errors.items(): + for e in errors: + self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + + self.cleaned_data['csv'] = connection_list + + +class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin): + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, + widget=forms.Select(attrs={'filter-for': 'pdu'})) + pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False, + widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}&is_pdu=True', + attrs={'filter-for': 'power_outlet'})) + livesearch = forms.CharField(required=False, label='PDU', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='pdu') + ) + power_outlet = forms.ModelChoiceField(queryset=PowerOutlet.objects.all(), label='Outlet', + widget=APISelect(api_url='/api/dcim/devices/{{pdu}}/power-outlets/', + disabled_indicator='connected_port')) + + class Meta: + model = PowerPort + fields = ['rack', 'pdu', 'livesearch', 'power_outlet', 'connection_status'] + labels = { + 'power_outlet': 'Outlet', + 'connection_status': 'Status', + } + + def __init__(self, *args, **kwargs): + + super(PowerPortConnectionForm, self).__init__(*args, **kwargs) + + if not self.instance.pk: + raise RuntimeError("PowerPortConnectionForm must be initialized with an existing PowerPort instance.") + + self.fields['rack'].queryset = Rack.objects.filter(site=self.instance.device.rack.site) + self.fields['power_outlet'].required = True + self.fields['connection_status'].choices = CONNECTION_STATUS_CHOICES + + # Initialize PDU choices + if self.is_bound and self.data.get('rack'): + self.fields['pdu'].queryset = Device.objects.filter(rack=self.data['rack'], device_type__is_pdu=True) + elif self.initial.get('rack', None): + self.fields['pdu'].queryset = Device.objects.filter(rack=self.initial['rack'], device_type__is_pdu=True) + else: + self.fields['pdu'].choices = [] + + # Initialize power outlet choices + if self.is_bound: + self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device__pk=self.data['pdu']) + elif self.initial.get('pdu', None): + self.fields['power_outlet'].queryset = PowerOutlet.objects.filter(device__pk=self.initial['pdu']) + else: + self.fields['power_outlet'].choices = [] + + +# +# Power outlets +# + +class PowerOutletForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = PowerOutlet + fields = ['device', 'name'] + widgets = { + 'device': forms.HiddenInput(), + } + + +class PowerOutletCreateForm(forms.Form, BootstrapMixin): + name_pattern = ExpandableNameField(label='Name') + + +class PowerOutletConnectionForm(forms.Form, BootstrapMixin): + rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, + widget=forms.Select(attrs={'filter-for': 'device'})) + device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, + widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack}}', + attrs={'filter-for': 'port'})) + livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='device') + ) + port = forms.ModelChoiceField(queryset=PowerPort.objects.all(), label='Port', + widget=APISelect(api_url='/api/dcim/devices/{{device}}/power-ports/', + disabled_indicator='power_outlet')) + connection_status = forms.BooleanField(required=False, initial=CONNECTION_STATUS_CONNECTED, label='Status', + widget=forms.Select(choices=CONNECTION_STATUS_CHOICES)) + + class Meta: + fields = ['rack', 'device', 'livesearch', 'port', 'connection_status'] + labels = { + 'connection_status': 'Status', + } + + def __init__(self, poweroutlet, *args, **kwargs): + + super(PowerOutletConnectionForm, self).__init__(*args, **kwargs) + + self.fields['rack'].queryset = Rack.objects.filter(site=poweroutlet.device.rack.site) + + # Initialize device choices + if self.is_bound and self.data.get('rack'): + self.fields['device'].queryset = Device.objects.filter(rack=self.data['rack']) + elif self.initial.get('rack', None): + self.fields['device'].queryset = Device.objects.filter(rack=self.initial['rack']) + else: + self.fields['device'].choices = [] + + # Initialize port choices + if self.is_bound: + self.fields['port'].queryset = PowerPort.objects.filter(device__pk=self.data['device']) + elif self.initial.get('device', None): + self.fields['port'].queryset = PowerPort.objects.filter(device_pk=self.initial['device']) + else: + self.fields['port'].choices = [] + + +# +# Interfaces +# + +class InterfaceForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = Interface + fields = ['device', 'name', 'form_factor', 'mgmt_only', 'description'] + widgets = { + 'device': forms.HiddenInput(), + } + + +class InterfaceCreateForm(forms.ModelForm, BootstrapMixin): + name_pattern = ExpandableNameField(label='Name') + + class Meta: + model = Interface + fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description'] + + +class InterfaceBulkCreateForm(InterfaceCreateForm, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + + +# +# Interface connections +# + +class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin): + interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface') + rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, + widget=forms.Select(attrs={'filter-for': 'device_b'})) + device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, + widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}', + attrs={'filter-for': 'interface_b'})) + livesearch = forms.CharField(required=False, label='Device', widget=Livesearch( + query_key='q', query_url='dcim-api:device_list', field_to_update='device_b') + ) + interface_b = forms.ModelChoiceField(queryset=Interface.objects.all(), label='Interface', + widget=APISelect(api_url='/api/dcim/devices/{{device_b}}/interfaces/?type=physical', + disabled_indicator='is_connected')) + + class Meta: + model = InterfaceConnection + fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status'] + + def __init__(self, device_a, *args, **kwargs): + + super(InterfaceConnectionForm, self).__init__(*args, **kwargs) + + self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site) + + # Initialize interface A choices + device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \ + .select_related('circuit', 'connected_as_a', 'connected_as_b') + self.fields['interface_a'].choices = [ + (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces + ] + + # Initialize device_b choices if rack_b is set + if self.is_bound and self.data.get('rack_b'): + self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b']) + elif self.initial.get('rack_b'): + self.fields['device_b'].queryset = Device.objects.filter(rack=self.initial['rack_b']) + else: + self.fields['device_b'].choices = [] + + # Initialize interface_b choices if device_b is set + if self.is_bound: + device_b_interfaces = Interface.objects.filter(device=self.data['device_b']) \ + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + elif self.initial.get('device_b'): + device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']) \ + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + else: + device_b_interfaces = [] + self.fields['interface_b'].choices = [ + (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_b_interfaces + ] + + +class InterfaceConnectionCSVForm(forms.Form): + device_a = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Device A not found.'}) + interface_a = forms.CharField() + device_b = FlexibleModelChoiceField(queryset=Device.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Device B not found.'}) + interface_b = forms.CharField() + status = forms.ChoiceField(choices=[('planned', 'Planned'), ('connected', 'Connected')]) + + def clean(self): + + # Validate interface A + if self.cleaned_data.get('device_a'): + try: + interface_a = Interface.objects.get(device=self.cleaned_data['device_a'], + name=self.cleaned_data['interface_a']) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface ({} {})" + .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a'])) + try: + InterfaceConnection.objects.get(Q(interface_a=interface_a) | Q(interface_b=interface_a)) + raise forms.ValidationError("{} {} is already connected" + .format(self.cleaned_data['device_a'], self.cleaned_data['interface_a'])) + except InterfaceConnection.DoesNotExist: + pass + + # Validate interface B + if self.cleaned_data.get('device_b'): + try: + interface_b = Interface.objects.get(device=self.cleaned_data['device_b'], + name=self.cleaned_data['interface_b']) + except Interface.DoesNotExist: + raise forms.ValidationError("Invalid interface ({} {})" + .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b'])) + try: + InterfaceConnection.objects.get(Q(interface_a=interface_b) | Q(interface_b=interface_b)) + raise forms.ValidationError("{} {} is already connected" + .format(self.cleaned_data['device_b'], self.cleaned_data['interface_b'])) + except InterfaceConnection.DoesNotExist: + pass + + +class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=InterfaceConnectionCSVForm) + + def clean(self): + records = self.cleaned_data.get('csv') + if not records: + return + + connection_list = [] + + for i, record in enumerate(records, start=1): + form = self.fields['csv'].csv_form(data=record) + if form.is_valid(): + interface_a = Interface.objects.get(device=form.cleaned_data['device_a'], + name=form.cleaned_data['interface_a']) + interface_b = Interface.objects.get(device=form.cleaned_data['device_b'], + name=form.cleaned_data['interface_b']) + connection = InterfaceConnection(interface_a=interface_a, interface_b=interface_b) + if form.cleaned_data['status'] == 'planned': + connection.connection_status = CONNECTION_STATUS_PLANNED + else: + connection.connection_status = CONNECTION_STATUS_CONNECTED + connection_list.append(connection) + else: + for field, errors in form.errors.items(): + for e in errors: + self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + + self.cleaned_data['csv'] = connection_list + + +class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin): + confirm = forms.BooleanField(required=True) + # Used for HTTP redirect upon successful deletion + device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False) + + +# +# Connections +# + +class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin): + site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + + +class PowerConnectionFilterForm(forms.Form, BootstrapMixin): + site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + + +class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin): + site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') + + +# +# IP addresses +# + +class IPAddressForm(forms.ModelForm, BootstrapMixin): + set_as_primary = forms.BooleanField(label='Set as primary IP for device', required=False) + + class Meta: + model = IPAddress + fields = ['address', 'vrf', 'interface', 'set_as_primary'] + help_texts = { + 'address': 'IPv4 or IPv6 address (with mask)' + } + + def __init__(self, device, *args, **kwargs): + + super(IPAddressForm, self).__init__(*args, **kwargs) + + self.fields['vrf'].empty_label = 'Global' + + self.fields['interface'].queryset = device.interfaces.all() + self.fields['interface'].required = True + + # If this device does not have any IP addresses assigned, default to setting the first IP as its primary + if not IPAddress.objects.filter(interface__device=device).count(): + self.fields['set_as_primary'].initial = True diff --git a/netbox/dcim/migrations/0001_initial.py b/netbox/dcim/migrations/0001_initial.py new file mode 100644 index 000000000..c1766d58d --- /dev/null +++ b/netbox/dcim/migrations/0001_initial.py @@ -0,0 +1,291 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-27 02:35 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import utilities.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='ConsolePort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('connection_status', models.NullBooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True)), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='ConsolePortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='ConsoleServerPort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ], + ), + migrations.CreateModel( + name='ConsoleServerPortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='Device', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', utilities.fields.NullableCharField(blank=True, max_length=50, null=True, unique=True)), + ('serial', models.CharField(blank=True, max_length=50, verbose_name=b'Serial number')), + ('position', models.PositiveSmallIntegerField(blank=True, help_text=b'Number of the lowest U position occupied by the device', null=True, validators=[django.core.validators.MinValueValidator(1)], verbose_name=b'Position (U)')), + ('face', models.PositiveSmallIntegerField(blank=True, choices=[[0, b'Front'], [1, b'Rear']], null=True, verbose_name=b'Rack face')), + ('status', models.BooleanField(choices=[[True, b'Active'], [False, b'Offline']], default=True, verbose_name=b'Status')), + ('ro_snmp', models.CharField(blank=True, max_length=50, verbose_name=b'SNMP (RO)')), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='DeviceRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('color', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='DeviceType', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('model', models.CharField(max_length=50)), + ('slug', models.SlugField()), + ('u_height', models.PositiveSmallIntegerField(default=1, verbose_name=b'Height (U)')), + ('is_full_depth', models.BooleanField(default=True, help_text=b'Device consumes both front and rear rack faces', verbose_name=b'Is full depth')), + ('is_console_server', models.BooleanField(default=False, help_text=b'Include this type of device in lists of console servers', verbose_name=b'Is a console server')), + ('is_pdu', models.BooleanField(default=False, help_text=b'Include this type of device in lists of PDUs', verbose_name=b'Is a PDU')), + ('is_network_device', models.BooleanField(default=True, help_text=b'This is a network device (e.g. switch, router, etc.)', verbose_name=b'Is a network device')), + ], + options={ + 'ordering': ['manufacturer', 'model'], + }, + ), + migrations.CreateModel( + name='Interface', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('form_factor', models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (Copper)'], [1000, b'1GE (Copper)'], [1100, b'1GE (SFP)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200)), + ('mgmt_only', models.BooleanField(default=False, verbose_name=b'OOB Management')), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.Device')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='InterfaceConnection', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('connection_status', models.BooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True, verbose_name=b'Status')), + ('interface_a', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='connected_as_a', to='dcim.Interface')), + ('interface_b', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='connected_as_b', to='dcim.Interface')), + ], + ), + migrations.CreateModel( + name='InterfaceTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('form_factor', models.PositiveSmallIntegerField(choices=[[0, b'Virtual'], [800, b'10/100M (Copper)'], [1000, b'1GE (Copper)'], [1100, b'1GE (SFP)'], [1200, b'10GE (SFP+)'], [1300, b'10GE (XFP)'], [1400, b'40GE (QSFP+)']], default=1200)), + ('mgmt_only', models.BooleanField(default=False, verbose_name=b'Management only')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='interface_templates', to='dcim.DeviceType')), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='Manufacturer', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='Module', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name=b'Name')), + ('part_id', models.CharField(blank=True, max_length=50, verbose_name=b'Part ID')), + ('serial', models.CharField(blank=True, max_length=50, verbose_name=b'Serial number')), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.Device')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='Platform', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('rpc_client', models.CharField(blank=True, choices=[[b'juniper-junos', b'Juniper Junos (NETCONF)'], [b'cisco-ios', b'Cisco IOS (SSH)'], [b'opengear', b'Opengear (SSH)']], max_length=30, verbose_name=b'RPC client')), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='PowerOutlet', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_outlets', to='dcim.Device')), + ], + ), + migrations.CreateModel( + name='PowerOutletTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_outlet_templates', to='dcim.DeviceType')), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='PowerPort', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('connection_status', models.NullBooleanField(choices=[[False, b'Planned'], [True, b'Connected']], default=True)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_ports', to='dcim.Device')), + ('power_outlet', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_port', to='dcim.PowerOutlet')), + ], + options={ + 'ordering': ['device', 'name'], + }, + ), + migrations.CreateModel( + name='PowerPortTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=30)), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='power_port_templates', to='dcim.DeviceType')), + ], + options={ + 'ordering': ['device_type', 'name'], + }, + ), + migrations.CreateModel( + name='Rack', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('facility_id', utilities.fields.NullableCharField(blank=True, max_length=30, null=True, verbose_name=b'Facility ID')), + ('u_height', models.PositiveSmallIntegerField(default=42, verbose_name=b'Height (U)')), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['site', 'name'], + }, + ), + migrations.CreateModel( + name='RackGroup', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('slug', models.SlugField()), + ], + options={ + 'ordering': ['site', 'name'], + }, + ), + migrations.CreateModel( + name='Site', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('facility', models.CharField(blank=True, max_length=50)), + ('asn', models.PositiveIntegerField(blank=True, null=True, verbose_name=b'ASN')), + ('physical_address', models.CharField(blank=True, max_length=200)), + ('shipping_address', models.CharField(blank=True, max_length=200)), + ('comments', models.TextField(blank=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='rackgroup', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='rack_groups', to='dcim.Site'), + ), + migrations.AddField( + model_name='rack', + name='group', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='racks', to='dcim.RackGroup'), + ), + migrations.AddField( + model_name='rack', + name='site', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.Site'), + ), + migrations.AddField( + model_name='devicetype', + name='manufacturer', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='device_types', to='dcim.Manufacturer'), + ), + migrations.AddField( + model_name='device', + name='device_role', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.DeviceRole'), + ), + migrations.AddField( + model_name='device', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='device', + name='platform', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='devices', to='dcim.Platform'), + ), + ] diff --git a/netbox/dcim/migrations/0002_auto_20160227_0235.py b/netbox/dcim/migrations/0002_auto_20160227_0235.py new file mode 100644 index 000000000..2cb19dc5f --- /dev/null +++ b/netbox/dcim/migrations/0002_auto_20160227_0235.py @@ -0,0 +1,114 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-27 02:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0001_initial'), + ('ipam', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='primary_ip', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='primary_for', to='ipam.IPAddress', verbose_name=b'Primary IP'), + ), + migrations.AddField( + model_name='device', + name='rack', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='dcim.Rack'), + ), + migrations.AddField( + model_name='consoleserverporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_port_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='consoleserverport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='cs_ports', to='dcim.Device'), + ), + migrations.AddField( + model_name='consoleporttemplate', + name='device_type', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_port_templates', to='dcim.DeviceType'), + ), + migrations.AddField( + model_name='consoleport', + name='cs_port', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='connected_console', to='dcim.ConsoleServerPort', verbose_name=b'Console server port'), + ), + migrations.AddField( + model_name='consoleport', + name='device', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='console_ports', to='dcim.Device'), + ), + migrations.AlterUniqueTogether( + name='rackgroup', + unique_together=set([('site', 'name'), ('site', 'slug')]), + ), + migrations.AlterUniqueTogether( + name='rack', + unique_together=set([('site', 'facility_id'), ('site', 'name')]), + ), + migrations.AlterUniqueTogether( + name='powerporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='powerport', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='poweroutlettemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='poweroutlet', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='module', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='interfacetemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='interface', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='devicetype', + unique_together=set([('manufacturer', 'slug'), ('manufacturer', 'model')]), + ), + migrations.AlterUniqueTogether( + name='device', + unique_together=set([('rack', 'position', 'face')]), + ), + migrations.AlterUniqueTogether( + name='consoleserverporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleserverport', + unique_together=set([('device', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleporttemplate', + unique_together=set([('device_type', 'name')]), + ), + migrations.AlterUniqueTogether( + name='consoleport', + unique_together=set([('device', 'name')]), + ), + ] diff --git a/netbox/dcim/migrations/__init__.py b/netbox/dcim/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py new file mode 100644 index 000000000..d906de7d8 --- /dev/null +++ b/netbox/dcim/models.py @@ -0,0 +1,686 @@ +from collections import OrderedDict + +from django.contrib.contenttypes.fields import GenericRelation +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse +from django.core.validators import MinValueValidator +from django.db import models +from django.db.models import Q, ObjectDoesNotExist + +from extras.rpc import RPC_CLIENTS +from secrets.models import Secret +from utilities.fields import NullableCharField + + +RACK_FACE_FRONT = 0 +RACK_FACE_REAR = 1 +RACK_FACE_CHOICES = [ + [RACK_FACE_FRONT, 'Front'], + [RACK_FACE_REAR, 'Rear'], +] + +COLOR_TEAL = 'teal' +COLOR_GREEN = 'green' +COLOR_BLUE = 'blue' +COLOR_PURPLE = 'purple' +COLOR_YELLOW = 'yellow' +COLOR_ORANGE = 'orange' +COLOR_RED = 'red' +COLOR_GRAY1 = 'light_gray' +COLOR_GRAY2 = 'medium_gray' +COLOR_GRAY3 = 'dark_gray' +DEVICE_ROLE_COLOR_CHOICES = [ + [COLOR_TEAL, 'Teal'], + [COLOR_GREEN, 'Green'], + [COLOR_BLUE, 'Blue'], + [COLOR_PURPLE, 'Purple'], + [COLOR_YELLOW, 'Yellow'], + [COLOR_ORANGE, 'Orange'], + [COLOR_RED, 'Red'], + [COLOR_GRAY1, 'Light Gray'], + [COLOR_GRAY2, 'Medium Gray'], + [COLOR_GRAY3, 'Dark Gray'], +] + +IFACE_FF_VIRTUAL = 0 +IFACE_FF_100M_COPPER = 800 +IFACE_FF_1GE_COPPER = 1000 +IFACE_FF_SFP = 1100 +IFACE_FF_SFP_PLUS = 1200 +IFACE_FF_XFP = 1300 +IFACE_FF_QSFP_PLUS = 1400 +IFACE_FF_CHOICES = [ + [IFACE_FF_VIRTUAL, 'Virtual'], + [IFACE_FF_100M_COPPER, '10/100M (Copper)'], + [IFACE_FF_1GE_COPPER, '1GE (Copper)'], + [IFACE_FF_SFP, '1GE (SFP)'], + [IFACE_FF_SFP_PLUS, '10GE (SFP+)'], + [IFACE_FF_XFP, '10GE (XFP)'], + [IFACE_FF_QSFP_PLUS, '40GE (QSFP+)'], +] + +STATUS_ACTIVE = True +STATUS_OFFLINE = False +STATUS_CHOICES = [ + [STATUS_ACTIVE, 'Active'], + [STATUS_OFFLINE, 'Offline'], +] + +CONNECTION_STATUS_PLANNED = False +CONNECTION_STATUS_CONNECTED = True +CONNECTION_STATUS_CHOICES = [ + [CONNECTION_STATUS_PLANNED, 'Planned'], + [CONNECTION_STATUS_CONNECTED, 'Connected'], +] + +# For mapping platform -> NC client +RPC_CLIENT_JUNIPER_JUNOS = 'juniper-junos' +RPC_CLIENT_CISCO_IOS = 'cisco-ios' +RPC_CLIENT_OPENGEAR = 'opengear' +RPC_CLIENT_CHOICES = [ + [RPC_CLIENT_JUNIPER_JUNOS, 'Juniper Junos (NETCONF)'], + [RPC_CLIENT_CISCO_IOS, 'Cisco IOS (SSH)'], + [RPC_CLIENT_OPENGEAR, 'Opengear (SSH)'], +] + + +class Site(models.Model): + """ + A physical site + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + facility = models.CharField(max_length=50, blank=True) + asn = models.PositiveIntegerField(blank=True, null=True, verbose_name='ASN') + physical_address = models.CharField(max_length=200, blank=True) + shipping_address = models.CharField(max_length=200, blank=True) + comments = models.TextField(blank=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + def get_absolute_url(self): + return reverse('dcim:site', args=[self.slug]) + + @property + def count_prefixes(self): + return self.prefixes.count() + + @property + def count_vlans(self): + return self.vlans.count() + + @property + def count_racks(self): + return Rack.objects.filter(site=self).count() + + @property + def count_devices(self): + return Device.objects.filter(rack__site=self).count() + + @property + def count_circuits(self): + return self.circuits.count() + + +class RackGroup(models.Model): + """ + An arbitrary grouping of Racks; e.g. a building or room. + """ + name = models.CharField(max_length=50) + slug = models.SlugField() + site = models.ForeignKey('Site', related_name='rack_groups') + + class Meta: + ordering = ['site', 'name'] + unique_together = [ + ['site', 'name'], + ['site', 'slug'], + ] + + def __unicode__(self): + return self.name + + +class Rack(models.Model): + """ + An equipment rack within a site (e.g. a 48U rack) + """ + name = models.CharField(max_length=50) + facility_id = NullableCharField(max_length=30, blank=True, null=True, verbose_name='Facility ID') + site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) + group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) + u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)') + comments = models.TextField(blank=True) + + class Meta: + ordering = ['site', 'name'] + unique_together = [ + ['site', 'name'], + ['site', 'facility_id'], + ] + + def __unicode__(self): + if self.facility_id: + return "{} ({})".format(self.name, self.facility_id) + return self.name + + def get_absolute_url(self): + return reverse('dcim:rack', args=[self.pk]) + + @property + def units(self): + return reversed(range(1, self.u_height + 1)) + + def get_rack_units(self, face=RACK_FACE_FRONT, remove_redundant=False): + """ + Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'} + Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy. + + :param face: Rack face (front or rear) + :param remove_redundant: If True, rack units occupied by a device already listed will be omitted + """ + + elevation = OrderedDict() + for u in reversed(range(1, self.u_height + 1)): + elevation[u] = {'id': u, 'name': 'U{}'.format(u), 'face': face, 'device': None} + + # Add devices to rack units list + if self.pk: + for device in Device.objects.select_related('device_type__manufacturer', 'device_role')\ + .filter(rack=self, position__gt=0).filter(Q(face=face) | Q(device_type__is_full_depth=True)): + if remove_redundant: + elevation[device.position]['device'] = device + for u in range(device.position + 1, device.position + device.device_type.u_height): + elevation.pop(u, None) + else: + for u in range(device.position, device.position + device.device_type.u_height): + elevation[u]['device'] = device + + return [u for u in elevation.values()] + + def get_front_elevation(self): + return self.get_rack_units(face=RACK_FACE_FRONT, remove_redundant=True) + + def get_rear_elevation(self): + return self.get_rack_units(face=RACK_FACE_REAR, remove_redundant=True) + + def get_available_units(self, u_height=1, rack_face=None, exclude=list()): + """ + Return a list of units within the rack available to accommodate a device of a given U height (default 1). + Optionally exclude one or more devices when calculating empty units (needed when moving a device from one + position to another within a rack). + + :param u_height: Minimum number of contiguous free units required + :param rack_face: The face of the rack (front or rear) required; 'None' if device is full depth + :param exclude: List of devices IDs to exclude (useful when moving a device within a rack) + """ + + # Gather all devices which consume U space within the rack + devices = self.devices.select_related().filter(position__gte=1).exclude(pk__in=exclude) + + # Initialize the rack unit skeleton + units = range(1, self.u_height + 1) + + # Remove units consumed by installed devices + for d in devices: + if rack_face is None or d.face == rack_face or d.device_type.is_full_depth: + for u in range(d.position, d.position + d.device_type.u_height): + try: + units.remove(u) + except ValueError: + # Found overlapping devices in the rack! + pass + + # Remove units without enough space above them to accommodate a device of the specified height + available_units = [] + for u in units: + if set(range(u, u + u_height)).issubset(units): + available_units.append(u) + + return list(reversed(available_units)) + + def get_0u_devices(self): + return self.devices.filter(position=0) + + +# +# Device Types +# + +class Manufacturer(models.Model): + """ + A hardware manufacturer + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class DeviceType(models.Model): + """ + A unique hardware type; manufacturer and model number (e.g. Juniper EX4300-48T) + """ + manufacturer = models.ForeignKey('Manufacturer', related_name='device_types', on_delete=models.PROTECT) + model = models.CharField(max_length=50) + slug = models.SlugField() + u_height = models.PositiveSmallIntegerField(verbose_name='Height (U)', default=1) + is_full_depth = models.BooleanField(default=True, verbose_name="Is full depth", + help_text="Device consumes both front and rear rack faces") + is_console_server = models.BooleanField(default=False, verbose_name='Is a console server', + help_text="Include this type of device in lists of console servers") + is_pdu = models.BooleanField(default=False, verbose_name='Is a PDU', + help_text="Include this type of device in lists of PDUs") + is_network_device = models.BooleanField(default=True, verbose_name='Is a network device', + help_text="This is a network device (e.g. switch, router, etc.)") + + class Meta: + ordering = ['manufacturer', 'model'] + unique_together = [ + ['manufacturer', 'model'], + ['manufacturer', 'slug'], + ] + + def __unicode__(self): + return "{0} {1}".format(self.manufacturer, self.model) + + +class ConsolePortTemplate(models.Model): + """ + A template for a ConsolePort to be created for a new device + """ + device_type = models.ForeignKey('DeviceType', related_name='console_port_templates', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __unicode__(self): + return self.name + + +class ConsoleServerPortTemplate(models.Model): + """ + A template for a ConsoleServerPort to be created for a new device + """ + device_type = models.ForeignKey('DeviceType', related_name='cs_port_templates', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __unicode__(self): + return self.name + + +class PowerPortTemplate(models.Model): + """ + A template for a PowerPort to be created for a new device + """ + device_type = models.ForeignKey('DeviceType', related_name='power_port_templates', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __unicode__(self): + return self.name + + +class PowerOutletTemplate(models.Model): + """ + A template for a PowerOutlet to be created for a new device + """ + device_type = models.ForeignKey('DeviceType', related_name='power_outlet_templates', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __unicode__(self): + return self.name + + +class InterfaceTemplate(models.Model): + """ + A template for a physical data interface on a new device + """ + device_type = models.ForeignKey('DeviceType', related_name='interface_templates', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS) + mgmt_only = models.BooleanField(default=False, verbose_name='Management only') + + class Meta: + ordering = ['device_type', 'name'] + unique_together = ['device_type', 'name'] + + def __unicode__(self): + return self.name + + +# +# Devices +# + +class DeviceRole(models.Model): + """ + The functional role of a device (e.g. router, switch, console server, etc.) + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class Platform(models.Model): + """ + A class of software running on a hardware device (e.g. Juniper Junos or Cisco IOS) + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + rpc_client = models.CharField(max_length=30, choices=RPC_CLIENT_CHOICES, blank=True, verbose_name='RPC client') + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class Device(models.Model): + """ + A physical piece of equipment mounted within a rack + """ + device_type = models.ForeignKey('DeviceType', related_name='instances', on_delete=models.PROTECT) + device_role = models.ForeignKey('DeviceRole', related_name='devices', on_delete=models.PROTECT) + platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL) + name = NullableCharField(max_length=50, blank=True, null=True, unique=True) + serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number') + rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT) + position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)], verbose_name='Position (U)', help_text='Number of the lowest U position occupied by the device') + face = models.PositiveSmallIntegerField(blank=True, null=True, choices=RACK_FACE_CHOICES, verbose_name='Rack face') + status = models.BooleanField(choices=STATUS_CHOICES, default=STATUS_ACTIVE, verbose_name='Status') + primary_ip = models.OneToOneField('ipam.IPAddress', related_name='primary_for', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='Primary IP') + ro_snmp = models.CharField(max_length=50, blank=True, verbose_name='SNMP (RO)') + comments = models.TextField(blank=True) + secrets = GenericRelation(Secret) + + class Meta: + ordering = ['name'] + unique_together = ['rack', 'position', 'face'] + + def __unicode__(self): + return self.display_name + + def get_absolute_url(self): + return reverse('dcim:device', args=[self.pk]) + + @property + def display_name(self): + if self.name: + return self.name + elif self.position: + return "{} ({} U{})".format(self.device_type, self.rack, self.position) + else: + return "{} ({})".format(self.device_type, self.rack) + + def clean(self): + + # Validate position/face combination + if self.position and self.face is None: + raise ValidationError("Must specify rack face with rack position.") + + # Validate rack space + rack_face = self.face if not self.device_type.is_full_depth else None + exclude_list = [self.pk] if self.pk else [] + try: + available_units = self.rack.get_available_units(u_height=self.device_type.u_height, rack_face=rack_face, + exclude=exclude_list) + if self.position and self.position not in available_units: + raise ValidationError("U{} is already occupied or does not have sufficient space to accommodate a(n) " + "{} ({}U).".format(self.position, self.device_type, self.device_type.u_height)) + except Rack.DoesNotExist: + pass + + def save(self, *args, **kwargs): + + is_new = not bool(self.pk) + + super(Device, self).save(*args, **kwargs) + + # If this is a new Device, instantiate all of the related components per the DeviceType definition + if is_new: + ConsolePort.objects.bulk_create( + [ConsolePort(device=self, name=template.name) for template in self.device_type.console_port_templates.all()] + ) + ConsoleServerPort.objects.bulk_create( + [ConsoleServerPort(device=self, name=template.name) for template in self.device_type.cs_port_templates.all()] + ) + PowerPort.objects.bulk_create( + [PowerPort(device=self, name=template.name) for template in self.device_type.power_port_templates.all()] + ) + PowerOutlet.objects.bulk_create( + [PowerOutlet(device=self, name=template.name) for template in self.device_type.power_outlet_templates.all()] + ) + Interface.objects.bulk_create( + [Interface(device=self, name=template.name, form_factor=template.form_factor, mgmt_only=template.mgmt_only) for template in self.device_type.interface_templates.all()] + ) + + def get_rpc_client(self): + """ + Return the appropriate RPC (e.g. NETCONF, ssh, etc.) client for this device's platform, if one is defined. + """ + if not self.platform: + return None + return RPC_CLIENTS.get(self.platform.rpc_client) + + +class ConsolePort(models.Model): + """ + A physical console port on a device + """ + device = models.ForeignKey('Device', related_name='console_ports', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + cs_port = models.OneToOneField('ConsoleServerPort', related_name='connected_console', on_delete=models.SET_NULL, verbose_name='Console server port', blank=True, null=True) + connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __unicode__(self): + return self.name + + +class ConsoleServerPortManager(models.Manager): + + def get_queryset(self): + """ + Include the trailing numeric portion of each port name to allow for proper ordering. + For example: + Port 1, Port 2, Port 3 ... Port 9, Port 10, Port 11 ... + Instead of: + Port 1, Port 10, Port 11 ... Port 19, Port 2, Port 20 ... + """ + return super(ConsoleServerPortManager, self).get_queryset().extra(select={ + 'name_as_integer': "CAST(substring(dcim_consoleserverport.name FROM '[0-9]+$') AS INTEGER)", + }).order_by('device', 'name_as_integer') + + +class ConsoleServerPort(models.Model): + """ + A physical port on a console server + """ + device = models.ForeignKey('Device', related_name='cs_ports', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + + objects = ConsoleServerPortManager() + + class Meta: + unique_together = ['device', 'name'] + + def __unicode__(self): + return self.name + + +class PowerPort(models.Model): + """ + A physical power supply (intake) port on a device + """ + device = models.ForeignKey('Device', related_name='power_ports', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + power_outlet = models.OneToOneField('PowerOutlet', related_name='connected_port', on_delete=models.SET_NULL, blank=True, null=True) + connection_status = models.NullBooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED) + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __unicode__(self): + return self.name + + +class PowerOutletManager(models.Manager): + + def get_queryset(self): + return super(PowerOutletManager, self).get_queryset().extra(select={ + 'name_padded': "CONCAT(SUBSTRING(dcim_poweroutlet.name FROM '^[^0-9]+'), LPAD(SUBSTRING(dcim_poweroutlet.name FROM '[0-9\/]+$'), 8, '0'))", + }).order_by('device', 'name_padded') + + +class PowerOutlet(models.Model): + """ + A physical power outlet (output) port on a device + """ + device = models.ForeignKey('Device', related_name='power_outlets', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + + objects = PowerOutletManager() + + class Meta: + unique_together = ['device', 'name'] + + def __unicode__(self): + return self.name + + +class InterfaceManager(models.Manager): + + def get_queryset(self): + """ + Cast up to three interface slot/position IDs as independent integers and order appropriately. This ensures that + interfaces are ordered numerically without regard to type. For example: + xe-0/0/0, xe-0/0/1, xe-0/0/2 ... et-0/0/47, et-0/0/48, et-0/0/49 ... + instead of: + et-0/0/48, et-0/0/49, et-0/0/50 ... et-0/0/53, xe-0/0/0, xe-0/0/1 ... + """ + return super(InterfaceManager, self).get_queryset().extra(select={ + '_id1': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)\/([0-9]+)$') AS integer)", + '_id2': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)\/([0-9]+)$') AS integer)", + '_id3': "CAST(SUBSTRING(dcim_interface.name FROM '([0-9]+)$') AS integer)", + }).order_by('device', '_id1', '_id2', '_id3') + + def virtual(self): + return self.get_queryset().filter(form_factor=IFACE_FF_VIRTUAL) + + def physical(self): + return self.get_queryset().exclude(form_factor=IFACE_FF_VIRTUAL) + + +class Interface(models.Model): + """ + A physical data interface on a device + """ + device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE) + name = models.CharField(max_length=30) + form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_SFP_PLUS) + mgmt_only = models.BooleanField(default=False, verbose_name='OOB Management') + description = models.CharField(max_length=100, blank=True) + + objects = InterfaceManager() + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __unicode__(self): + return self.name + + @property + def is_physical(self): + return self.form_factor != IFACE_FF_VIRTUAL + + @property + def is_connected(self): + try: + return bool(self.circuit) + except ObjectDoesNotExist: + pass + return bool(self.connection) + + @property + def connection(self): + try: + return self.connected_as_a + except ObjectDoesNotExist: + pass + try: + return self.connected_as_b + except ObjectDoesNotExist: + pass + return None + + def get_connected_interface(self): + try: + connection = InterfaceConnection.objects.select_related().get(Q(interface_a=self) | Q(interface_b=self)) + if connection.interface_a == self: + return connection.interface_b + else: + return connection.interface_a + except InterfaceConnection.DoesNotExist: + return None + except InterfaceConnection.MultipleObjectsReturned as e: + raise e("Multiple connections found for {0} interface {1}!".format(self.device, self)) + + +class InterfaceConnection(models.Model): + """ + A symmetrical, one-to-one connection between two device interfaces + """ + interface_a = models.OneToOneField('Interface', related_name='connected_as_a', on_delete=models.CASCADE) + interface_b = models.OneToOneField('Interface', related_name='connected_as_b', on_delete=models.CASCADE) + connection_status = models.BooleanField(choices=CONNECTION_STATUS_CHOICES, default=CONNECTION_STATUS_CONNECTED, verbose_name='Status') + + +class Module(models.Model): + """ + A hardware module belonging to a device. Used for inventory purposes only. + """ + device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE) + name = models.CharField(max_length=50, verbose_name='Name') + part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) + serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) + + class Meta: + ordering = ['device', 'name'] + unique_together = ['device', 'name'] + + def __unicode__(self): + return self.name diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py new file mode 100644 index 000000000..1a49a8b0a --- /dev/null +++ b/netbox/dcim/tables.py @@ -0,0 +1,165 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from .models import Site, Rack, Device, ConsolePort, PowerPort + + +PREFIXES_PER_VLAN = """ +{% for p in record.prefix_set.all %} + {{ p }} + {% if not forloop.last %}
{% endif %} +{% endfor %} +""" + +STATUS_LABEL = """ + + {{ record.status.name }} + +""" + +DEVICE_LINK = """ +{{ record.name|default:'Unnamed device' }} +""" + + +# +# Sites +# + +class SiteTable(tables.Table): + name = tables.LinkColumn('dcim:site', args=[Accessor('slug')], verbose_name='Name') + facility = tables.Column(verbose_name='Facility') + asn = tables.Column(verbose_name='ASN') + rack_count = tables.Column(accessor=Accessor('count_racks'), orderable=False, verbose_name='Racks') + device_count = tables.Column(accessor=Accessor('count_devices'), orderable=False, verbose_name='Devices') + prefix_count = tables.Column(accessor=Accessor('count_prefixes'), orderable=False, verbose_name='Prefixes') + vlan_count = tables.Column(accessor=Accessor('count_vlans'), orderable=False, verbose_name='VLANs') + circuit_count = tables.Column(accessor=Accessor('count_circuits'), orderable=False, verbose_name='Circuits') + + class Meta: + model = Site + fields = ('name', 'facility', 'asn', 'rack_count', 'device_count', 'prefix_count', 'vlan_count', 'circuit_count') + empty_text = "No sites have been defined." + attrs = { + 'class': 'table table-hover', + } + + +# +# Racks +# + +class RackTable(tables.Table): + name = tables.LinkColumn('dcim:rack', args=[Accessor('pk')], verbose_name='Name') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + group = tables.Column(verbose_name='Group') + facility_id = tables.Column(verbose_name='Facility ID') + u_height = tables.Column(verbose_name='Height (U)') + devices = tables.Column(accessor=Accessor('device_count'), orderable=False, verbose_name='Devices') + + class Meta: + model = Rack + fields = ('name', 'site', 'group', 'facility_id', 'u_height') + empty_text = "No racks were found." + attrs = { + 'class': 'table table-hover', + } + + +class RackBulkEditTable(RackTable): + pk = tables.CheckBoxColumn() + + class Meta(RackTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'name', 'site', 'group', 'facility_id', 'u_height') + + +# +# Devices +# + +class DeviceTable(tables.Table): + name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') + site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site') + rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') + device_role = tables.Column(verbose_name='Role') + device_type = tables.Column(verbose_name='Type') + primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address', template_code="{{ record.primary_ip.address.ip }}") + + class Meta: + model = Device + fields = ('name', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') + empty_text = "No devices were found." + attrs = { + 'class': 'table table-hover', + } + + +class DeviceBulkEditTable(DeviceTable): + pk = tables.CheckBoxColumn() + + class Meta(DeviceTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'name', 'site', 'rack', 'device_role', 'device_type', 'primary_ip') + + +class DeviceImportTable(tables.Table): + name = tables.TemplateColumn(template_code=DEVICE_LINK, verbose_name='Name') + site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site') + rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack') + position = tables.Column(verbose_name='Position') + device_role = tables.Column(verbose_name='Role') + device_type = tables.Column(verbose_name='Type') + + class Meta: + model = Device + fields = ('name', 'site', 'rack', 'position', 'device_role', 'device_type') + attrs = { + 'class': 'table table-hover', + } + + +# +# Device connections +# + +class ConsoleConnectionTable(tables.Table): + console_server = tables.LinkColumn('dcim:device', accessor=Accessor('cs_port.device'), args=[Accessor('cs_port.device.pk')], verbose_name='Console server') + cs_port = tables.Column(verbose_name='Port') + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + name = tables.Column(verbose_name='Console port') + + class Meta: + model = ConsolePort + fields = ('console_server', 'cs_port', 'device', 'name') + attrs = { + 'class': 'table table-hover', + } + + +class PowerConnectionTable(tables.Table): + pdu = tables.LinkColumn('dcim:device', accessor=Accessor('power_outlet.device'), args=[Accessor('power_outlet.device.pk')], verbose_name='PDU') + power_outlet = tables.Column(verbose_name='Outlet') + device = tables.LinkColumn('dcim:device', args=[Accessor('device.pk')], verbose_name='Device') + name = tables.Column(verbose_name='Console port') + + class Meta: + model = PowerPort + fields = ('pdu', 'power_outlet', 'device', 'name') + attrs = { + 'class': 'table table-hover', + } + + +class InterfaceConnectionTable(tables.Table): + device_a = tables.LinkColumn('dcim:device', accessor=Accessor('interface_a.device'), args=[Accessor('interface_a.device.pk')], verbose_name='Device A') + interface_a = tables.Column(verbose_name='Interface A') + device_b = tables.LinkColumn('dcim:device', accessor=Accessor('interface_b.device'), args=[Accessor('interface_b.device.pk')], verbose_name='Device B') + interface_b = tables.Column(verbose_name='Interface B') + + class Meta: + model = PowerPort + fields = ('device_a', 'interface_a', 'device_b', 'interface_b') + attrs = { + 'class': 'table table-hover', + } diff --git a/netbox/dcim/tests/__init__.py b/netbox/dcim/tests/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py new file mode 100644 index 000000000..6ca891b76 --- /dev/null +++ b/netbox/dcim/tests/test_forms.py @@ -0,0 +1,71 @@ +from django.test import TestCase +from dcim.forms import * +from dcim.models import * + + +def get_id(model, slug): + return model.objects.get(slug=slug).id + + +class DeviceTestCase(TestCase): + + fixtures = ['dcim', 'ipam'] + + def test_racked_device(self): + test = DeviceForm(data={ + 'device_role': get_id(DeviceRole, 'leaf-switch'), + 'name': 'test', + 'site': get_id(Site, 'test1'), + 'face': RACK_FACE_FRONT, + 'platform': get_id(Platform, 'juniper-junos'), + 'device_type': get_id(DeviceType, 'qfx5100-48s'), + 'position': 41, + 'rack': '1', + 'manufacturer': get_id(Manufacturer, 'juniper'), + }) + self.assertTrue(test.is_valid(), test.fields['position'].choices) + self.assertTrue(test.save()) + + def test_racked_device_occupied(self): + test = DeviceForm(data={ + 'device_role': get_id(DeviceRole, 'leaf-switch'), + 'name': 'test', + 'site': get_id(Site, 'test1'), + 'face': RACK_FACE_FRONT, + 'platform': get_id(Platform, 'juniper-junos'), + 'device_type': get_id(DeviceType, 'qfx5100-48s'), + 'position': 1, + 'rack': '1', + 'manufacturer': get_id(Manufacturer, 'juniper'), + }) + self.assertFalse(test.is_valid()) + + def test_non_racked_device(self): + test = DeviceForm(data={ + 'device_role': get_id(DeviceRole, 'pdu'), + 'name': 'test', + 'site': get_id(Site, 'test1'), + 'face': None, + 'platform': None, + 'device_type': get_id(DeviceType, 'cwg-24vym415c9'), + 'position': None, + 'rack': '1', + 'manufacturer': get_id(Manufacturer, 'servertech'), + }) + self.assertTrue(test.is_valid()) + self.assertTrue(test.save()) + + def test_non_racked_device_with_face(self): + test = DeviceForm(data={ + 'device_role': get_id(DeviceRole, 'pdu'), + 'name': 'test', + 'site': get_id(Site, 'test1'), + 'face': RACK_FACE_REAR, + 'platform': None, + 'device_type': get_id(DeviceType, 'cwg-24vym415c9'), + 'position': None, + 'rack': '1', + 'manufacturer': get_id(Manufacturer, 'servertech'), + }) + self.assertTrue(test.is_valid()) + self.assertTrue(test.save()) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py new file mode 100644 index 000000000..ca841ea8f --- /dev/null +++ b/netbox/dcim/tests/test_models.py @@ -0,0 +1,96 @@ +from django.test import TestCase +from dcim.models import * + + +class RackTestCase(TestCase): + + def setUp(self): + + site = Site.objects.create( + name='TestSite1', + slug='my-test-site' + ) + self.rack = Rack.objects.create( + name='TestRack1', + facility_id='A101', + site=site, + u_height=42 + ) + self.manufacturer = Manufacturer.objects.create( + name='Acme', + slug='acme' + ) + + self.device_type = { + 'ff2048': DeviceType.objects.create( + manufacturer=self.manufacturer, + model='FrameForwarder 2048', + slug='ff2048' + ), + 'cc5000': DeviceType.objects.create( + manufacturer=self.manufacturer, + model='CurrentCatapult 5000', + slug='cc5000', + u_height=0 + ), + } + self.role = { + 'Server': DeviceRole.objects.create( + name='Server', + slug='server', + ), + 'Switch': DeviceRole.objects.create( + name='Switch', + slug='switch', + ), + 'Console Server': DeviceRole.objects.create( + name='Console Server', + slug='console-server', + ), + 'PDU': DeviceRole.objects.create( + name='PDU', + slug='pdu', + ), + + } + + def test_mount_single_device(self): + + rack1 = Rack.objects.get(name='TestRack1') + device1 = Device( + name='TestSwitch1', + device_type=DeviceType.objects.get(manufacturer__slug='acme', slug='ff2048'), + device_role=DeviceRole.objects.get(slug='switch'), + rack=rack1, + position=10, + face=RACK_FACE_REAR, + ) + device1.save() + + # Validate rack height + self.assertEqual(list(rack1.units), list(reversed(range(1, 43)))) + + # Validate inventory (front face) + rack1_inventory_front = rack1.get_front_elevation() + self.assertEqual(rack1_inventory_front[-10]['device'], device1) + del(rack1_inventory_front[-10]) + for u in rack1_inventory_front: + self.assertIsNone(u['device']) + + # Validate inventory (rear face) + rack1_inventory_rear = rack1.get_rear_elevation() + self.assertEqual(rack1_inventory_rear[-10]['device'], device1) + del(rack1_inventory_rear[-10]) + for u in rack1_inventory_rear: + self.assertIsNone(u['device']) + + def test_mount_zero_ru(self): + pdu = Device.objects.create( + name='TestPDU', + device_role=self.role.get('PDU'), + device_type=self.device_type.get('cc5000'), + rack=self.rack, + position=None, + face=None, + ) + self.assertTrue(pdu) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py new file mode 100644 index 000000000..5e79c634c --- /dev/null +++ b/netbox/dcim/urls.py @@ -0,0 +1,86 @@ +from django.conf.urls import url + +from secrets.views import secret_add + +from . import views + +urlpatterns = [ + + # Sites + url(r'^sites/$', views.site_list, name='site_list'), + url(r'^sites/add/$', views.site_add, name='site_add'), + url(r'^sites/import/$', views.SiteBulkImportView.as_view(), name='site_import'), + url(r'^sites/(?P[\w-]+)/$', views.site, name='site'), + url(r'^sites/(?P[\w-]+)/edit/$', views.site_edit, name='site_edit'), + url(r'^sites/(?P[\w-]+)/delete/$', views.site_delete, name='site_delete'), + + # Racks + url(r'^racks/$', views.rack_list, name='rack_list'), + url(r'^racks/add/$', views.rack_add, name='rack_add'), + url(r'^racks/import/$', views.RackBulkImportView.as_view(), name='rack_import'), + url(r'^racks/edit/$', views.RackBulkEditView.as_view(), name='rack_bulk_edit'), + url(r'^racks/delete/$', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), + url(r'^racks/(?P\d+)/$', views.rack, name='rack'), + url(r'^racks/(?P\d+)/edit/$', views.rack_edit, name='rack_edit'), + url(r'^racks/(?P\d+)/delete/$', views.rack_delete, name='rack_delete'), + + # Devices + url(r'^devices/$', views.device_list, name='device_list'), + url(r'^devices/add/$', views.device_add, name='device_add'), + url(r'^devices/import/$', views.DeviceBulkImportView.as_view(), name='device_import'), + url(r'^devices/edit/$', views.DeviceBulkEditView.as_view(), name='device_bulk_edit'), + url(r'^devices/delete/$', views.DeviceBulkDeleteView.as_view(), name='device_bulk_delete'), + url(r'^devices/(?P\d+)/$', views.device, name='device'), + url(r'^devices/(?P\d+)/edit/$', views.device_edit, name='device_edit'), + url(r'^devices/(?P\d+)/delete/$', views.device_delete, name='device_delete'), + url(r'^devices/(?P\d+)/inventory/$', views.device_inventory, name='device_inventory'), + url(r'^devices/(?P\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'), + url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), + url(r'^devices/(?P\d+)/add-secret/$', secret_add, {'parent_model': 'dcim.Device'}, + name='device_addsecret'), + + # Console ports + url(r'^devices/(?P\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'), + url(r'^console-ports/(?P\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), + url(r'^console-ports/(?P\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), + url(r'^console-ports/(?P\d+)/edit/$', views.consoleport_edit, name='consoleport_edit'), + url(r'^console-ports/(?P\d+)/delete/$', views.consoleport_delete, name='consoleport_delete'), + + # Console server ports + url(r'^devices/(?P\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'), + url(r'^console-server-ports/(?P\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), + url(r'^console-server-ports/(?P\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), + url(r'^console-server-ports/(?P\d+)/edit/$', views.consoleserverport_edit, name='consoleserverport_edit'), + url(r'^console-server-ports/(?P\d+)/delete/$', views.consoleserverport_delete, name='consoleserverport_delete'), + + # Power ports + url(r'^devices/(?P\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'), + url(r'^power-ports/(?P\d+)/connect/$', views.powerport_connect, name='powerport_connect'), + url(r'^power-ports/(?P\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), + url(r'^power-ports/(?P\d+)/edit/$', views.powerport_edit, name='powerport_edit'), + url(r'^power-ports/(?P\d+)/delete/$', views.powerport_delete, name='powerport_delete'), + + # Power outlets + url(r'^devices/(?P\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'), + url(r'^power-outlets/(?P\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), + url(r'^power-outlets/(?P\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), + url(r'^power-outlets/(?P\d+)/edit/$', views.poweroutlet_edit, name='poweroutlet_edit'), + url(r'^power-outlets/(?P\d+)/delete/$', views.poweroutlet_delete, name='poweroutlet_delete'), + + # Console/power/interface connections + url(r'^console-connections/$', views.ConsoleConnectionsListView.as_view(), name='console_connections_list'), + url(r'^console-connections/import/$', views.ConsoleConnectionsBulkImportView.as_view(), name='console_connections_import'), + url(r'^power-connections/$', views.PowerConnectionsListView.as_view(), name='power_connections_list'), + url(r'^power-connections/import/$', views.PowerConnectionsBulkImportView.as_view(), name='power_connections_import'), + url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), + url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), + + # Interfaces + url(r'^devices/interfaces/add/$', views.InterfaceBulkAddView.as_view(), name='interface_bulk_add'), + url(r'^devices/(?P\d+)/interfaces/add/$', views.interface_add, name='interface_add'), + url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), + url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), + url(r'^interfaces/(?P\d+)/edit/$', views.interface_edit, name='interface_edit'), + url(r'^interfaces/(?P\d+)/delete/$', views.interface_delete, name='interface_delete'), + +] diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py new file mode 100644 index 000000000..6e9781b21 --- /dev/null +++ b/netbox/dcim/views.py @@ -0,0 +1,1444 @@ +import re + +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.urlresolvers import reverse +from django.db.models import Count, ProtectedError +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.http import urlencode + +from django_tables2 import RequestConfig +from extras.models import ExportTemplate +from utilities.error_handlers import handle_protectederror +from utilities.forms import ConfirmationForm +from utilities.paginator import EnhancedPaginator +from utilities.views import ObjectListView, BulkImportView, BulkEditView, BulkDeleteView +from ipam.models import Prefix, IPAddress, VLAN +from circuits.models import Circuit + +from .filters import RackFilter, DeviceFilter, ConsoleConnectionFilter, PowerConnectionFilter, InterfaceConnectionFilter +from .forms import SiteForm, SiteImportForm, RackForm, RackImportForm, RackBulkEditForm, RackBulkDeleteForm, \ + RackFilterForm, DeviceForm, DeviceImportForm, DeviceBulkEditForm, DeviceBulkDeleteForm, DeviceFilterForm, \ + ConsolePortForm, ConsolePortCreateForm, ConsolePortConnectionForm, ConsoleConnectionImportForm, \ + ConsoleServerPortForm, ConsoleServerPortCreateForm, ConsoleServerPortConnectionForm, PowerPortForm, \ + PowerPortCreateForm, PowerPortConnectionForm, PowerConnectionImportForm, PowerOutletForm, PowerOutletCreateForm, \ + PowerOutletConnectionForm, InterfaceForm, InterfaceCreateForm, InterfaceBulkCreateForm, InterfaceConnectionForm, \ + InterfaceConnectionDeletionForm, InterfaceConnectionImportForm, ConsoleConnectionFilterForm, \ + PowerConnectionFilterForm, InterfaceConnectionFilterForm, IPAddressForm +from .models import Site, Rack, Device, ConsolePort, ConsoleServerPort, PowerPort, \ + PowerOutlet, Interface, InterfaceConnection, Module, CONNECTION_STATUS_CONNECTED +from .tables import SiteTable, RackTable, RackBulkEditTable, DeviceTable, DeviceBulkEditTable, DeviceImportTable, \ + ConsoleConnectionTable, PowerConnectionTable, InterfaceConnectionTable + + +EXPANSION_PATTERN = '\[(\d+-\d+)\]' + + +def xstr(s): + """ + Replace None with an empty string (for CSV export) + """ + return '' if s is None else str(s) + + +def expand_pattern(string): + """ + Expand a numeric pattern into a list of strings. Examples: + 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3'] + 'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] + """ + lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1) + x, y = pattern.split('-') + for i in range(int(x), int(y) + 1): + if remnant: + for string in expand_pattern(remnant): + yield "{0}{1}{2}".format(lead, i, string) + else: + yield "{0}{1}".format(lead, i) + + +# +# Sites +# + +def site_list(request): + + queryset = Site.objects.all() + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='site', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_sites') + return response + + site_table = SiteTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(site_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='site') + + return render(request, 'dcim/site_list.html', { + 'site_table': site_table, + 'export_templates': export_templates, + }) + + +def site(request, slug): + + site = get_object_or_404(Site, slug=slug) + stats = { + 'rack_count': Rack.objects.filter(site=site).count(), + 'device_count': Device.objects.filter(rack__site=site).count(), + 'prefix_count': Prefix.objects.filter(site=site).count(), + 'vlan_count': VLAN.objects.filter(site=site).count(), + 'circuit_count': Circuit.objects.filter(site=site).count(), + } + + return render(request, 'dcim/site.html', { + 'site': site, + 'stats': stats, + }) + + +@permission_required('dcim.add_site') +def site_add(request): + + if request.method == 'POST': + form = SiteForm(request.POST) + if form.is_valid(): + site = form.save() + messages.success(request, "Added new site: {0}".format(site.name)) + if '_addanother' in request.POST: + return redirect('dcim:site_add') + else: + return redirect('dcim:site', slug=site.slug) + + else: + form = SiteForm() + + return render(request, 'dcim/site_edit.html', { + 'form': form, + 'cancel_url': reverse('dcim:site_list'), + }) + + +@permission_required('dcim.change_site') +def site_edit(request, slug): + + site = get_object_or_404(Site, slug=slug) + + if request.method == 'POST': + form = SiteForm(request.POST, instance=site) + if form.is_valid(): + site = form.save() + messages.success(request, "Modified site {0}".format(site.name)) + return redirect('dcim:site', slug=site.slug) + + else: + form = SiteForm(instance=site) + + return render(request, 'dcim/site_edit.html', { + 'site': site, + 'form': form, + 'cancel_url': reverse('dcim:site', kwargs={'slug': site.slug}), + }) + + +@permission_required('dcim.delete_site') +def site_delete(request, slug): + + site = get_object_or_404(Site, slug=slug) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + site.delete() + messages.success(request, "Site {0} has been deleted".format(site)) + return redirect('dcim:site_list') + except ProtectedError, e: + handle_protectederror(site, request, e) + return redirect('dcim:site', slug=site.slug) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/site_delete.html', { + 'site': site, + 'form': form, + 'cancel_url': reverse('dcim:site', kwargs={'slug': site.slug}), + }) + + +class SiteBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_site' + form = SiteImportForm + table = SiteTable + template_name = 'dcim/site_import.html' + obj_list_url = 'dcim:site_list' + + +# +# Racks +# + +def rack_list(request): + + queryset = Rack.objects.select_related('site').annotate(device_count=Count('devices', distinct=True)) + queryset = RackFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='rack', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_racks') + return response + + # Hot-wire direct to rack view if only one rack was returned + if queryset.count() == 1: + return redirect('dcim:rack', pk=queryset[0].pk) + + if request.user.has_perm('dcim.change_rack') or request.user.has_perm('dcim.delete_rack'): + rack_table = RackBulkEditTable(queryset) + else: + rack_table = RackTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(rack_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='rack') + + return render(request, 'dcim/rack_list.html', { + 'rack_table': rack_table, + 'export_templates': export_templates, + 'filter_form': RackFilterForm(request.GET, label_suffix=''), + }) + + +def rack(request, pk): + + rack = get_object_or_404(Rack, pk=pk) + + nonracked_devices = Device.objects.filter(rack=rack, position__isnull=True) + try: + next_rack = Rack.objects.filter(site=rack.site, name__gt=rack.name).order_by('name')[0] + except IndexError: + next_rack = None + try: + prev_rack = Rack.objects.filter(site=rack.site, name__lt=rack.name).order_by('-name')[0] + except IndexError: + prev_rack = None + + return render(request, 'dcim/rack.html', { + 'rack': rack, + 'nonracked_devices': nonracked_devices, + 'next_rack': next_rack, + 'prev_rack': prev_rack, + 'front_elevation': rack.get_front_elevation(), + 'rear_elevation': rack.get_rear_elevation(), + }) + + +@permission_required('dcim.add_rack') +def rack_add(request): + + if request.method == 'POST': + form = RackForm(request.POST) + if form.is_valid(): + rack = form.save() + messages.success(request, "Added new rack to {}: {}".format(rack.site.name, rack)) + if '_addanother' in request.POST: + base_url = reverse('dcim:rack_add') + params = urlencode({ + 'site': rack.site.pk, + }) + return HttpResponseRedirect('{}?{}'.format(base_url, params)) + else: + return redirect('dcim:rack', pk=rack.pk) + + else: + form = RackForm() + + return render(request, 'dcim/rack_edit.html', { + 'form': form, + 'cancel_url': reverse('dcim:rack_list'), + }) + + +@permission_required('dcim.change_rack') +def rack_edit(request, pk): + + rack = get_object_or_404(Rack, pk=pk) + + if request.method == 'POST': + form = RackForm(request.POST, instance=rack) + if form.is_valid(): + rack = form.save() + messages.success(request, "Modified rack {0}".format(rack.name)) + return redirect('dcim:rack', pk=rack.pk) + + else: + form = RackForm(instance=rack) + + return render(request, 'dcim/rack_edit.html', { + 'rack': rack, + 'form': form, + 'cancel_url': reverse('dcim:rack', kwargs={'pk': rack.pk}), + }) + + +@permission_required('dcim.delete_rack') +def rack_delete(request, pk): + + rack = get_object_or_404(Rack, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + rack.delete() + messages.success(request, "Rack {0} has been deleted".format(rack)) + return redirect('dcim:rack_list') + except ProtectedError, e: + handle_protectederror(rack, request, e) + return redirect('dcim:rack', pk=rack.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/rack_delete.html', { + 'rack': rack, + 'form': form, + 'cancel_url': reverse('dcim:rack', kwargs={'pk': rack.pk}), + }) + + +class RackBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_rack' + form = RackImportForm + table = RackTable + template_name = 'dcim/rack_import.html' + obj_list_url = 'dcim:rack_list' + + +class RackBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_rack' + cls = Rack + form = RackBulkEditForm + template_name = 'dcim/rack_bulk_edit.html' + redirect_url = 'dcim:rack_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['site', 'group', 'u_height', 'comments']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} racks".format(updated_count)) + + +class RackBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rack' + cls = Rack + form = RackBulkDeleteForm + template_name = 'dcim/rack_bulk_delete.html' + redirect_url = 'dcim:rack_list' + + +# +# Devices +# + +def device_list(request): + + queryset = Device.objects.select_related('device_type', 'device_type__manufacturer', 'device_role', 'rack', 'rack__site', 'primary_ip') + queryset = DeviceFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='device', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_devices') + return response + + # Hot-wire direct to device view if only one device was returned + if queryset.count() == 1: + return redirect('dcim:device', pk=queryset[0].pk) + + if request.user.has_perm('dcim.change_device') or request.user.has_perm('dcim.delete_device'): + device_table = DeviceBulkEditTable(queryset) + else: + device_table = DeviceTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(device_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='device') + + return render(request, 'dcim/device_list.html', { + 'device_table': device_table, + 'export_templates': export_templates, + 'filter_form': DeviceFilterForm(request.GET, label_suffix=''), + }) + + +def device(request, pk): + + device = get_object_or_404(Device, pk=pk) + console_ports = ConsolePort.objects.filter(device=device).select_related('cs_port__device') + cs_ports = ConsoleServerPort.objects.filter(device=device).select_related('connected_console') + power_ports = PowerPort.objects.filter(device=device).select_related('power_outlet__device') + power_outlets = PowerOutlet.objects.filter(device=device).select_related('connected_port') + interfaces = Interface.objects.filter(device=device, mgmt_only=False).select_related('connected_as_a', 'connected_as_b', 'circuit') + mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related('connected_as_a', 'connected_as_b', 'circuit') + + # Gather any secrets which belong to this device + secrets = device.secrets.all() + + # Find all IP addresses assigned to this device + ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface').order_by('interface') + + # Find any related devices for convenient linking in the UI + related_devices = [] + if device.name: + if re.match('.+[0-9]+$', device.name): + # Strip 1 or more trailing digits (e.g. core-switch1) + base_name = re.match('(.*?)[0-9]+$', device.name).group(1) + elif re.match('.+\d[a-z]+$', device.name.lower()): + # Strip a trailing letter if preceded by a digit (e.g. dist-switch3a -> dist-switch3) + base_name = re.match('(.*\d+)[a-z]$', device.name.lower()).group(1) + else: + base_name = None + if base_name: + related_devices = Device.objects.filter(name__istartswith=base_name).exclude(pk=device.pk).select_related('rack', 'device_type__manufacturer')[:10] + + return render(request, 'dcim/device.html', { + 'device': device, + 'console_ports': console_ports, + 'cs_ports': cs_ports, + 'power_ports': power_ports, + 'power_outlets': power_outlets, + 'interfaces': interfaces, + 'mgmt_interfaces': mgmt_interfaces, + 'ip_addresses': ip_addresses, + 'secrets': secrets, + 'related_devices': related_devices, + }) + + +@permission_required('dcim.add_device') +def device_add(request): + + if request.method == 'POST': + form = DeviceForm(request.POST) + if form.is_valid(): + device = form.save() + messages.success(request, "Added new device: {0} ({1})".format(device.name, device.device_type)) + if '_addanother' in request.POST: + base_url = reverse('dcim:device_add') + params = urlencode({ + 'site': device.rack.site.pk, + 'rack': device.rack.pk, + }) + return HttpResponseRedirect('{}?{}'.format(base_url, params)) + else: + return redirect('dcim:device', pk=device.pk) + + else: + initial_data = {} + if request.GET.get('rack', None): + try: + rack = Rack.objects.get(pk=request.GET.get('rack', None)) + initial_data['rack'] = rack.pk + initial_data['site'] = rack.site.pk + initial_data['position'] = request.GET.get('position') + initial_data['face'] = request.GET.get('face') + except Rack.DoesNotExist: + pass + form = DeviceForm(initial=initial_data) + + return render(request, 'dcim/device_edit.html', { + 'form': form, + 'cancel_url': reverse('dcim:device_list'), + }) + + +@permission_required('dcim.change_device') +def device_edit(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = DeviceForm(request.POST, instance=device) + if form.is_valid(): + device = form.save() + messages.success(request, "Modified device {0}".format(device.name)) + return redirect('dcim:device', pk=device.pk) + + else: + form = DeviceForm(instance=device) + + return render(request, 'dcim/device_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.delete_device') +def device_delete(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + device.delete() + messages.success(request, "Device {0} has been deleted".format(device)) + return redirect('dcim:device_list') + except ProtectedError, e: + handle_protectederror(device, request, e) + return redirect('dcim:device', pk=device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/device_delete.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +class DeviceBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.add_device' + form = DeviceImportForm + table = DeviceImportTable + template_name = 'dcim/device_import.html' + obj_list_url = 'dcim:device_list' + + +class DeviceBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.change_device' + cls = Device + form = DeviceBulkEditForm + template_name = 'dcim/device_bulk_edit.html' + redirect_url = 'dcim:device_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + if form.cleaned_data['platform']: + fields_to_update['platform'] = form.cleaned_data['platform'] + elif form.cleaned_data['platform_delete']: + fields_to_update['platform'] = None + if form.cleaned_data['status']: + status = form.cleaned_data['status'] + fields_to_update['status'] = True if status == 'True' else False + for field in ['device_type', 'device_role', 'serial', 'ro_snmp']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} devices".format(updated_count)) + + +class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_device' + cls = Device + form = DeviceBulkDeleteForm + template_name = 'dcim/device_bulk_delete.html' + redirect_url = 'dcim:device_list' + + +def device_inventory(request, pk): + + device = get_object_or_404(Device, pk=pk) + modules = Module.objects.filter(device=device) + + return render(request, 'dcim/device_inventory.html', { + 'device': device, + 'modules': modules, + }) + + +def device_lldp_neighbors(request, pk): + + device = get_object_or_404(Device, pk=pk) + interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b') + + return render(request, 'dcim/device_lldp_neighbors.html', { + 'device': device, + 'interfaces': interfaces, + }) + + +# +# Console ports +# + +@permission_required('dcim.add_consoleport') +def consoleport_add(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = ConsolePortCreateForm(request.POST) + if form.is_valid(): + + console_ports = [] + for name in form.cleaned_data['name_pattern']: + cp_form = ConsolePortForm({ + 'device': device.pk, + 'name': name, + }) + if cp_form.is_valid(): + console_ports.append(cp_form.save(commit=False)) + else: + form.add_error('name_pattern', "Duplicate console port name for this device: {}".format(name)) + + if not form.errors: + ConsolePort.objects.bulk_create(console_ports) + messages.success(request, "Added {} console port(s) to {}".format(len(console_ports), device)) + if '_addanother' in request.POST: + return redirect('dcim:consoleport_add', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = ConsolePortCreateForm() + + return render(request, 'dcim/consoleport_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.change_consoleport') +def consoleport_connect(request, pk): + + consoleport = get_object_or_404(ConsolePort, pk=pk) + + if request.method == 'POST': + form = ConsolePortConnectionForm(request.POST, instance=consoleport) + if form.is_valid(): + consoleport = form.save() + messages.success(request, "Connected {0} {1} to {2} {3}".format( + consoleport.device, + consoleport.name, + consoleport.cs_port.device, + consoleport.cs_port.name, + )) + return redirect('dcim:device', pk=consoleport.device.pk) + + else: + form = ConsolePortConnectionForm(instance=consoleport, initial={ + 'rack': consoleport.device.rack, + 'connection_status': CONNECTION_STATUS_CONNECTED, + }) + + return render(request, 'dcim/consoleport_connect.html', { + 'consoleport': consoleport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) + + +@permission_required('dcim.change_consoleport') +def consoleport_disconnect(request, pk): + + consoleport = get_object_or_404(ConsolePort, pk=pk) + + if not consoleport.cs_port: + messages.warning(request, "Cannot disconnect console port {0}: It is not connected to anything".format(consoleport)) + return redirect('dcim:device', pk=consoleport.device.pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + consoleport.cs_port = None + consoleport.connection_status = None + consoleport.save() + messages.success(request, "Console port {0} has been disconnected".format(consoleport)) + return redirect('dcim:device', pk=consoleport.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/consoleport_disconnect.html', { + 'consoleport': consoleport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) + + +@permission_required('dcim.change_consoleport') +def consoleport_edit(request, pk): + + consoleport = get_object_or_404(ConsolePort, pk=pk) + + if request.method == 'POST': + form = ConsolePortForm(request.POST, instance=consoleport) + if form.is_valid(): + consoleport = form.save() + messages.success(request, "Modified {0} {1}".format(consoleport.device.name, consoleport.name)) + return redirect('dcim:device', pk=consoleport.device.pk) + + else: + form = ConsolePortForm(instance=consoleport) + + return render(request, 'dcim/consoleport_edit.html', { + 'consoleport': consoleport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) + + +@permission_required('dcim.delete_consoleport') +def consoleport_delete(request, pk): + + consoleport = get_object_or_404(ConsolePort, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + consoleport.delete() + messages.success(request, "Console port {0} has been deleted from {1}".format(consoleport, consoleport.device)) + return redirect('dcim:device', pk=consoleport.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/consoleport_delete.html', { + 'consoleport': consoleport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleport.device.pk}), + }) + + +class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.change_consoleport' + form = ConsoleConnectionImportForm + table = ConsoleConnectionTable + template_name = 'dcim/console_connections_import.html' + + +# +# Console server ports +# + +@permission_required('dcim.add_consoleserverport') +def consoleserverport_add(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = ConsoleServerPortCreateForm(request.POST) + if form.is_valid(): + + cs_ports = [] + for name in form.cleaned_data['name_pattern']: + csp_form = ConsoleServerPortForm({ + 'device': device.pk, + 'name': name, + }) + if csp_form.is_valid(): + cs_ports.append(csp_form.save(commit=False)) + else: + form.add_error('name_pattern', "Duplicate console server port name for this device: {}" + .format(name)) + + if not form.errors: + ConsoleServerPort.objects.bulk_create(cs_ports) + messages.success(request, "Added {} console server port(s) to {}".format(len(cs_ports), device)) + if '_addanother' in request.POST: + return redirect('dcim:consoleserverport_add', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = ConsoleServerPortCreateForm() + + return render(request, 'dcim/consoleserverport_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.change_consoleserverport') +def consoleserverport_connect(request, pk): + + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + + if request.method == 'POST': + form = ConsoleServerPortConnectionForm(consoleserverport, request.POST) + if form.is_valid(): + consoleport = form.cleaned_data['port'] + consoleport.cs_port = consoleserverport + consoleport.connection_status = form.cleaned_data['connection_status'] + consoleport.save() + messages.success(request, "Connected {0} {1} to {2} {3}".format( + consoleport.device, + consoleport.name, + consoleserverport.device, + consoleserverport.name, + )) + return redirect('dcim:device', pk=consoleserverport.device.pk) + + else: + form = ConsoleServerPortConnectionForm(consoleserverport, initial={'rack': consoleserverport.device.rack}) + + return render(request, 'dcim/consoleserverport_connect.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) + + +@permission_required('dcim.change_consoleserverport') +def consoleserverport_disconnect(request, pk): + + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + + if not hasattr(consoleserverport, 'connected_console'): + messages.warning(request, "Cannot disconnect console server port {0}: Nothing is connected to it".format(consoleserverport)) + return redirect('dcim:device', pk=consoleserverport.device.pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + consoleport = consoleserverport.connected_console + consoleport.cs_port = None + consoleport.connection_status = None + consoleport.save() + messages.success(request, "Console server port {0} has been disconnected".format(consoleserverport)) + return redirect('dcim:device', pk=consoleserverport.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/consoleserverport_disconnect.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) + + +@permission_required('dcim.change_consoleserverport') +def consoleserverport_edit(request, pk): + + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + + if request.method == 'POST': + form = ConsoleServerPortForm(request.POST, instance=consoleserverport) + if form.is_valid(): + consoleserverport = form.save() + messages.success(request, "Modified {0} {1}".format(consoleserverport.device.name, consoleserverport.name)) + return redirect('dcim:device', pk=consoleserverport.device.pk) + + else: + form = ConsoleServerPortForm(instance=consoleserverport) + + return render(request, 'dcim/consoleserverport_edit.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) + + +@permission_required('dcim.delete_consoleserverport') +def consoleserverport_delete(request, pk): + + consoleserverport = get_object_or_404(ConsoleServerPort, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + consoleserverport.delete() + messages.success(request, "Console server port {0} has been deleted from {1}".format(consoleserverport, consoleserverport.device)) + return redirect('dcim:device', pk=consoleserverport.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/consoleserverport_delete.html', { + 'consoleserverport': consoleserverport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': consoleserverport.device.pk}), + }) + + +# +# Power ports +# + +@permission_required('dcim.add_powerport') +def powerport_add(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = PowerPortCreateForm(request.POST) + if form.is_valid(): + + power_ports = [] + for name in form.cleaned_data['name_pattern']: + pp_form = PowerPortForm({ + 'device': device.pk, + 'name': name, + }) + if pp_form.is_valid(): + power_ports.append(pp_form.save(commit=False)) + else: + form.add_error('name_pattern', "Duplicate power port name for this device: {}".format(name)) + + if not form.errors: + PowerPort.objects.bulk_create(power_ports) + messages.success(request, "Added {} power port(s) to {}".format(len(power_ports), device)) + if '_addanother' in request.POST: + return redirect('dcim:powerport_add', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = PowerPortCreateForm() + + return render(request, 'dcim/powerport_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.change_powerport') +def powerport_connect(request, pk): + + powerport = get_object_or_404(PowerPort, pk=pk) + + if request.method == 'POST': + form = PowerPortConnectionForm(request.POST, instance=powerport) + if form.is_valid(): + powerport = form.save() + messages.success(request, "Connected {0} {1} to {2} {3}".format( + powerport.device, + powerport.name, + powerport.power_outlet.device, + powerport.power_outlet.name, + )) + return redirect('dcim:device', pk=powerport.device.pk) + + else: + form = PowerPortConnectionForm(instance=powerport, initial={ + 'rack': powerport.device.rack, + 'connection_status': CONNECTION_STATUS_CONNECTED, + }) + + return render(request, 'dcim/powerport_connect.html', { + 'powerport': powerport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) + + +@permission_required('dcim.change_powerport') +def powerport_disconnect(request, pk): + + powerport = get_object_or_404(PowerPort, pk=pk) + + if not powerport.power_outlet: + messages.warning(request, "Cannot disconnect power port {0}: It is not connected to an outlet".format(powerport)) + return redirect('dcim:device', pk=powerport.device.pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + powerport.power_outlet = None + powerport.connection_status = None + powerport.save() + messages.success(request, "Power port {0} has been disconnected".format(powerport)) + return redirect('dcim:device', pk=powerport.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/powerport_disconnect.html', { + 'powerport': powerport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) + + +@permission_required('dcim.change_powerport') +def powerport_edit(request, pk): + + powerport = get_object_or_404(PowerPort, pk=pk) + + if request.method == 'POST': + form = PowerPortForm(request.POST, instance=powerport) + if form.is_valid(): + powerport = form.save() + messages.success(request, "Modified {0} power port {1}".format(powerport.device.name, powerport.name)) + return redirect('dcim:device', pk=powerport.device.pk) + + else: + form = PowerPortForm(instance=powerport) + + return render(request, 'dcim/powerport_edit.html', { + 'powerport': powerport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) + + +@permission_required('dcim.delete_powerport') +def powerport_delete(request, pk): + + powerport = get_object_or_404(PowerPort, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + powerport.delete() + messages.success(request, "Power port {0} has been deleted from {1}".format(powerport, powerport.device)) + return redirect('dcim:device', pk=powerport.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/powerport_delete.html', { + 'powerport': powerport, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': powerport.device.pk}), + }) + + +class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.change_powerport' + form = PowerConnectionImportForm + table = PowerConnectionTable + template_name = 'dcim/power_connections_import.html' + + +# +# Power outlets +# + +@permission_required('dcim.add_poweroutlet') +def poweroutlet_add(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = PowerOutletCreateForm(request.POST) + if form.is_valid(): + + power_outlets = [] + for name in form.cleaned_data['name_pattern']: + po_form = PowerOutletForm({ + 'device': device.pk, + 'name': name, + }) + if po_form.is_valid(): + power_outlets.append(po_form.save(commit=False)) + else: + form.add_error('name_pattern', "Duplicate power outlet name for this device: {}".format(name)) + + if not form.errors: + PowerOutlet.objects.bulk_create(power_outlets) + messages.success(request, "Added {} power outlet(s) to {}".format(len(power_outlets), device)) + if '_addanother' in request.POST: + return redirect('dcim:poweroutlet_add', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = PowerOutletCreateForm() + + return render(request, 'dcim/poweroutlet_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.change_poweroutlet') +def poweroutlet_connect(request, pk): + + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + + if request.method == 'POST': + form = PowerOutletConnectionForm(poweroutlet, request.POST) + if form.is_valid(): + powerport = form.cleaned_data['port'] + powerport.power_outlet = poweroutlet + powerport.connection_status = form.cleaned_data['connection_status'] + powerport.save() + messages.success(request, "Connected {0} {1} to {2} {3}".format( + powerport.device, + powerport.name, + poweroutlet.device, + poweroutlet.name, + )) + return redirect('dcim:device', pk=poweroutlet.device.pk) + + else: + form = PowerOutletConnectionForm(poweroutlet, initial={'rack': poweroutlet.device.rack}) + + return render(request, 'dcim/poweroutlet_connect.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) + + +@permission_required('dcim.change_poweroutlet') +def poweroutlet_disconnect(request, pk): + + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + + if not hasattr(poweroutlet, 'connected_port'): + messages.warning(request, "Cannot disconnectpower outlet {0}: Nothing is connected to it".format(poweroutlet)) + return redirect('dcim:device', pk=poweroutlet.device.pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + powerport = poweroutlet.connected_port + powerport.power_outlet = None + powerport.connection_status = None + powerport.save() + messages.success(request, "Power outlet {0} has been disconnected".format(poweroutlet)) + return redirect('dcim:device', pk=poweroutlet.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/poweroutlet_disconnect.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) + + +@permission_required('dcim.change_poweroutlet') +def poweroutlet_edit(request, pk): + + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + + if request.method == 'POST': + form = PowerOutletForm(request.POST, instance=poweroutlet) + if form.is_valid(): + poweroutlet = form.save() + messages.success(request, "Modified {0} power outlet {1}".format(poweroutlet.device.name, poweroutlet.name)) + return redirect('dcim:device', pk=poweroutlet.device.pk) + + else: + form = PowerOutletForm(instance=poweroutlet) + + return render(request, 'dcim/poweroutlet_edit.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) + + +@permission_required('dcim.delete_poweroutlet') +def poweroutlet_delete(request, pk): + + poweroutlet = get_object_or_404(PowerOutlet, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + poweroutlet.delete() + messages.success(request, "Power outlet {0} has been deleted from {1}".format(poweroutlet, poweroutlet.device)) + return redirect('dcim:device', pk=poweroutlet.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/poweroutlet_delete.html', { + 'poweroutlet': poweroutlet, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': poweroutlet.device.pk}), + }) + + +# +# Interfaces +# + +@permission_required('dcim.add_interface') +def interface_add(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = InterfaceCreateForm(request.POST) + if form.is_valid(): + + interfaces = [] + for name in form.cleaned_data['name_pattern']: + iface_form = InterfaceForm({ + 'device': device.pk, + 'name': name, + 'form_factor': form.cleaned_data['form_factor'], + 'mgmt_only': form.cleaned_data['mgmt_only'], + 'description': form.cleaned_data['description'], + }) + if iface_form.is_valid(): + interfaces.append(iface_form.save(commit=False)) + else: + form.add_error('name_pattern', "Duplicate interface name for this device: {}".format(name)) + + if not form.errors: + Interface.objects.bulk_create(interfaces) + messages.success(request, "Added {} interface(s) to {}".format(len(interfaces), device)) + if '_addanother' in request.POST: + return redirect('dcim:interface_add', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = InterfaceCreateForm() + + return render(request, 'dcim/interface_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.change_interface') +def interface_edit(request, pk): + + interface = get_object_or_404(Interface, pk=pk) + + if request.method == 'POST': + form = InterfaceForm(request.POST, instance=interface) + if form.is_valid(): + interface = form.save() + messages.success(request, "Modified {0} interface {1}".format(interface.device.name, interface.name)) + return redirect('dcim:device', pk=interface.device.pk) + + else: + form = InterfaceForm(instance=interface) + + return render(request, 'dcim/interface_edit.html', { + 'interface': interface, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': interface.device.pk}), + }) + + +@permission_required('dcim.delete_interface') +def interface_delete(request, pk): + + interface = get_object_or_404(Interface, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + interface.delete() + messages.success(request, "Interface {0} has been deleted from {1}".format(interface, interface.device)) + return redirect('dcim:device', pk=interface.device.pk) + + else: + form = ConfirmationForm() + + return render(request, 'dcim/interface_delete.html', { + 'interface': interface, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': interface.device.pk}), + }) + + +class InterfaceBulkAddView(PermissionRequiredMixin, BulkEditView): + permission_required = 'dcim.add_interface' + cls = Device + form = InterfaceBulkCreateForm + template_name = 'dcim/interface_bulk_add.html' + redirect_url = 'dcim:device_list' + + def update_objects(self, pk_list, form): + + selected_devices = Device.objects.filter(pk__in=pk_list) + interfaces = [] + + for device in selected_devices: + for name in form.cleaned_data['name_pattern']: + iface_form = InterfaceForm({ + 'device': device.pk, + 'name': name, + 'form_factor': form.cleaned_data['form_factor'], + 'mgmt_only': form.cleaned_data['mgmt_only'], + 'description': form.cleaned_data['description'], + }) + if iface_form.is_valid(): + interfaces.append(iface_form.save(commit=False)) + else: + form.add_error(None, "Duplicate interface {} found for device {}".format(name, device)) + + if not form.errors: + Interface.objects.bulk_create(interfaces) + messages.success(self.request, "Added {} interfaces to {} devices".format(len(interfaces), + len(selected_devices))) + + +# +# Interface connections +# + +@permission_required('dcim.add_interfaceconnection') +def interfaceconnection_add(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = InterfaceConnectionForm(device, request.POST) + if form.is_valid(): + interfaceconnection = form.save() + messages.success(request, "Connected {0} {1} to {2} {3}".format( + interfaceconnection.interface_a.device, + interfaceconnection.interface_a, + interfaceconnection.interface_b.device, + interfaceconnection.interface_b, + )) + if '_addanother' in request.POST: + base_url = reverse('dcim:interfaceconnection_add', kwargs={'pk': device.pk}) + params = urlencode({ + 'rack_b': interfaceconnection.interface_b.device.rack.pk, + 'device_b': interfaceconnection.interface_b.device.pk, + }) + return HttpResponseRedirect('{}?{}'.format(base_url, params)) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = InterfaceConnectionForm(device, initial={ + 'interface_a': request.GET.get('interface', None), + 'rack_b': request.GET.get('rack_b', None), + 'device_b': request.GET.get('device_b', None), + }) + + return render(request, 'dcim/interfaceconnection_edit.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) + + +@permission_required('dcim.delete_interfaceconnection') +def interfaceconnection_delete(request, pk): + + interfaceconnection = get_object_or_404(InterfaceConnection, pk=pk) + device_id = request.GET.get('device', None) + + if request.method == 'POST': + form = InterfaceConnectionDeletionForm(request.POST) + if form.is_valid(): + interfaceconnection.delete() + messages.success(request, "Deleted the connection between {0} {1} and {2} {3}".format( + interfaceconnection.interface_a.device, + interfaceconnection.interface_a, + interfaceconnection.interface_b.device, + interfaceconnection.interface_b, + )) + if form.cleaned_data['device']: + return redirect('dcim:device', pk=form.cleaned_data['device'].pk) + else: + return redirect('dcim:device_list') + + else: + form = InterfaceConnectionDeletionForm(initial={ + 'device': device_id, + }) + + # Determine where to direct user upon cancellation + if device_id: + cancel_url = reverse('dcim:device', kwargs={'pk': device_id}) + else: + cancel_url = reverse('dcim:device_list') + + return render(request, 'dcim/interfaceconnection_delete.html', { + 'interfaceconnection': interfaceconnection, + 'device_id': device_id, + 'form': form, + 'cancel_url': cancel_url, + }) + + +class InterfaceConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'dcim.change_interface' + form = InterfaceConnectionImportForm + table = InterfaceConnectionTable + template_name = 'dcim/interface_connections_import.html' + + +# +# Connections +# + +class ConsoleConnectionsListView(ObjectListView): + queryset = ConsolePort.objects.select_related('device', 'cs_port__device').filter(cs_port__isnull=False)\ + .order_by('cs_port__device__name', 'cs_port__name') + filter = ConsoleConnectionFilter + filter_form = ConsoleConnectionFilterForm + table = ConsoleConnectionTable + template_name = 'dcim/console_connections_list.html' + + +class PowerConnectionsListView(ObjectListView): + queryset = PowerPort.objects.select_related('device', 'power_outlet__device').filter(power_outlet__isnull=False)\ + .order_by('power_outlet__device__name', 'power_outlet__name') + filter = PowerConnectionFilter + filter_form = PowerConnectionFilterForm + table = PowerConnectionTable + template_name = 'dcim/power_connections_list.html' + + +class InterfaceConnectionsListView(ObjectListView): + queryset = InterfaceConnection.objects.select_related('interface_a__device', 'interface_b__device')\ + .order_by('interface_a__device__name', 'interface_a__name') + filter = InterfaceConnectionFilter + filter_form = InterfaceConnectionFilterForm + table = InterfaceConnectionTable + template_name = 'dcim/interface_connections_list.html' + + +# +# IP addresses +# + +@permission_required('ipam.add_ipaddress') +def ipaddress_assign(request, pk): + + device = get_object_or_404(Device, pk=pk) + + if request.method == 'POST': + form = IPAddressForm(device, request.POST) + if form.is_valid(): + + ipaddress = form.save(commit=False) + ipaddress.interface = form.cleaned_data['interface'] + ipaddress.save() + messages.success(request, "Added new IP address {0} to interface {1}".format(ipaddress, ipaddress.interface)) + + if form.cleaned_data['set_as_primary']: + device.primary_ip = ipaddress + device.save() + + if '_addanother' in request.POST: + return redirect('dcim:ipaddress_assign', pk=device.pk) + else: + return redirect('dcim:device', pk=device.pk) + + else: + form = IPAddressForm(device) + + return render(request, 'dcim/ipaddress_assign.html', { + 'device': device, + 'form': form, + 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), + }) diff --git a/netbox/extras/__init__.py b/netbox/extras/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py new file mode 100644 index 000000000..ac584b048 --- /dev/null +++ b/netbox/extras/admin.py @@ -0,0 +1,13 @@ +from django.contrib import admin + +from .models import Graph, ExportTemplate + + +@admin.register(Graph) +class GraphAdmin(admin.ModelAdmin): + list_display = ['name', 'type', 'weight', 'source'] + + +@admin.register(ExportTemplate) +class ExportTemplateAdmin(admin.ModelAdmin): + list_display = ['content_type', 'name', 'mime_type', 'file_extension'] diff --git a/netbox/extras/api/__init__.py b/netbox/extras/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/api/renderers.py b/netbox/extras/api/renderers.py new file mode 100644 index 000000000..0a464c3b1 --- /dev/null +++ b/netbox/extras/api/renderers.py @@ -0,0 +1,31 @@ +from rest_framework import renderers + + +# IP address family designations +AF = { + 4: 'A', + 6: 'AAAA', +} + + +class BINDZoneRenderer(renderers.BaseRenderer): + """ + Generate a BIND zone file from a list of DNS records. + Required fields: `name`, `primary_ip` + """ + media_type = 'text/plain' + format = 'bind-zone' + + def render(self, data, media_type=None, renderer_context=None): + records = [] + for record in data: + if record.get('name') and record.get('primary_ip'): + try: + records.append("{} IN {} {}".format( + record['name'], + AF[record['primary_ip']['family']], + record['primary_ip']['address'].split('/')[0], + )) + except KeyError: + pass + return '\n'.join(records) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py new file mode 100644 index 000000000..e5a17bfa9 --- /dev/null +++ b/netbox/extras/api/serializers.py @@ -0,0 +1,14 @@ +from rest_framework import serializers + +from extras.models import Graph + + +class GraphSerializer(serializers.ModelSerializer): + embed_url = serializers.SerializerMethodField() + + class Meta: + model = Graph + fields = ['name', 'embed_url', 'link'] + + def get_embed_url(self, obj): + return obj.embed_url(self.context['graphed_object']) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py new file mode 100644 index 000000000..01563ecaf --- /dev/null +++ b/netbox/extras/api/views.py @@ -0,0 +1,33 @@ +from rest_framework import generics + +from django.http import Http404 +from django.shortcuts import get_object_or_404 + +from circuits.models import Provider +from dcim.models import Site, Interface +from extras.models import Graph, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_PROVIDER, GRAPH_TYPE_SITE +from .serializers import GraphSerializer + + +class GraphListView(generics.ListAPIView): + """ + Returns a list of relevant graphs + """ + serializer_class = GraphSerializer + + def get_serializer_context(self): + cls = { + GRAPH_TYPE_INTERFACE: Interface, + GRAPH_TYPE_PROVIDER: Provider, + GRAPH_TYPE_SITE: Site, + } + context = super(GraphListView, self).get_serializer_context() + context.update({'graphed_object': get_object_or_404(cls[self.kwargs.get('type')], pk=self.kwargs['pk'])}) + return context + + def get_queryset(self): + graph_type = self.kwargs.get('type', None) + if not graph_type: + raise Http404() + queryset = Graph.objects.filter(type=graph_type) + return queryset diff --git a/netbox/extras/fixtures/extras.yaml b/netbox/extras/fixtures/extras.yaml new file mode 100644 index 000000000..de6c9b556 --- /dev/null +++ b/netbox/extras/fixtures/extras.yaml @@ -0,0 +1,12 @@ +- model: extras.graph + pk: 1 + fields: {type: 300, weight: 1000, name: Site Test Graph, source: 'http://localhost/na.png', + link: ''} +- model: extras.graph + pk: 2 + fields: {type: 200, weight: 1000, name: Provider Test Graph, source: 'http://localhost/provider_graph.png', + link: ''} +- model: extras.graph + pk: 3 + fields: {type: 100, weight: 1000, name: Interface Test Graph, source: 'http://localhost/interface_graph.png', + link: ''} diff --git a/netbox/extras/management/__init__.py b/netbox/extras/management/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/management/commands/__init__.py b/netbox/extras/management/commands/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/management/commands/run_inventory.py b/netbox/extras/management/commands/run_inventory.py new file mode 100644 index 000000000..ed85dd305 --- /dev/null +++ b/netbox/extras/management/commands/run_inventory.py @@ -0,0 +1,117 @@ +from Exscript.protocols.Exception import LoginFailure +from getpass import getpass +from ncclient.transport.errors import AuthenticationError + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction + +from dcim.models import Device, Module, Site + + +class Command(BaseCommand): + help = "Update inventory information for specified devices" + username = settings.NETBOX_USERNAME + password = settings.NETBOX_PASSWORD + + def add_arguments(self, parser): + parser.add_argument('-u', '--username', dest='username', help="Specify the username to use") + parser.add_argument('-p', '--password', action='store_true', default=False, help="Prompt for password to use") + parser.add_argument('-s', '--site', dest='site', action='append', help="Filter devices by site (include argument once per site)") + parser.add_argument('-n', '--name', dest='name', help="Filter devices by name (regular expression)") + parser.add_argument('--full', action='store_true', default=False, help="For inventory update for all devices") + parser.add_argument('--fake', action='store_true', default=False, help="Do not actually update database") + + def handle(self, *args, **options): + + # Credentials + if options['username']: + self.username = options['username'] + if options['password']: + self.password = getpass("Password: ") + + device_list = Device.objects.filter() + + # --site: Include only devices belonging to specified site(s) + if options['site']: + sites = Site.objects.filter(slug__in=options['site']) + if sites: + site_names = [s.name for s in sites] + self.stdout.write("Running inventory for these sites: {}".format(', '.join(site_names))) + else: + raise CommandError("One or more sites specified but none found.") + device_list = device_list.filter(rack__site__in=sites) + + # --name: Filter devices by name matching a regex + if options['name']: + device_list = device_list.filter(name__iregex=options['name']) + + # --full: Gather inventory data for *all* devices + if options['full']: + self.stdout.write("WARNING: Running inventory for all devices! Prior data will be overwritten. (--full)") + + # --fake: Gathering data but not updating the database + if options['fake']: + self.stdout.write("WARNING: Inventory data will not be saved! (--fake)") + + device_count = device_list.count() + self.stdout.write("** Found {} devices...".format(device_count)) + + for i, device in enumerate(device_list, start=1): + + self.stdout.write("[{}/{}] {}: ".format(i, device_count, device.name), ending='') + + # Skip inactive devices + if not device.status: + self.stdout.write("Skipped (inactive)") + continue + + # Skip devices without primary_ip set + if not device.primary_ip: + self.stdout.write("Skipped (no primary IP set)") + continue + + # Skip devices which have already been inventoried if not doing a full update + if device.serial and not options['full']: + self.stdout.write("Skipped (Serial: {})".format(device.serial)) + continue + + RPC = device.get_rpc_client() + if not RPC: + self.stdout.write("Skipped (no RPC client available for platform {})".format(device.platform)) + continue + + # Connect to device and retrieve inventory info + try: + with RPC(device, self.username, self.password) as rpc_client: + inventory = rpc_client.get_inventory() + except KeyboardInterrupt: + raise + except (AuthenticationError, LoginFailure): + self.stdout.write("Authentication error!") + continue + except Exception as e: + self.stdout.write("Error for {} ({}): {}".format(device, device.primary_ip.address.ip, e)) + continue + + self.stdout.write("") + self.stdout.write("\tSerial: {}".format(inventory['chassis']['serial'])) + self.stdout.write("\tDescription: {}".format(inventory['chassis']['description'])) + for module in inventory['modules']: + self.stdout.write("\tModule: {} / {} ({})".format(module['name'], module['part_id'], module['serial'])) + + if not options['fake']: + with transaction.atomic(): + if inventory['chassis']['serial']: + device.serial = inventory['chassis']['serial'] + device.save() + Module.objects.filter(device=device).delete() + modules = [] + for module in inventory['modules']: + modules.append(Module(device=device, + name=module['name'], + part_id=module['part_id'], + serial=module['serial'])) + Module.objects.bulk_create(modules) + + self.stdout.write("Finished!") diff --git a/netbox/extras/migrations/0001_initial.py b/netbox/extras/migrations/0001_initial.py new file mode 100644 index 000000000..3d370b539 --- /dev/null +++ b/netbox/extras/migrations/0001_initial.py @@ -0,0 +1,50 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-27 02:35 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.CreateModel( + name='ExportTemplate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=200)), + ('template_code', models.TextField()), + ('mime_type', models.CharField(blank=True, max_length=15)), + ('file_extension', models.CharField(blank=True, max_length=15)), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['content_type', 'name'], + }, + ), + migrations.CreateModel( + name='Graph', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('type', models.PositiveSmallIntegerField(choices=[(100, b'Interface'), (200, b'Provider'), (300, b'Site')])), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('name', models.CharField(max_length=100, verbose_name=b'Name')), + ('source', models.CharField(max_length=500, verbose_name=b'Source URL')), + ('link', models.URLField(blank=True, verbose_name=b'Link URL')), + ], + options={ + 'ordering': ['type', 'weight', 'name'], + }, + ), + migrations.AlterUniqueTogether( + name='exporttemplate', + unique_together=set([('content_type', 'name')]), + ), + ] diff --git a/netbox/extras/migrations/__init__.py b/netbox/extras/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/extras/models.py b/netbox/extras/models.py new file mode 100644 index 000000000..12f09aedd --- /dev/null +++ b/netbox/extras/models.py @@ -0,0 +1,70 @@ +from django.contrib.contenttypes.models import ContentType +from django.db import models +from django.http import HttpResponse +from django.template import Template, Context + + +GRAPH_TYPE_INTERFACE = 100 +GRAPH_TYPE_PROVIDER = 200 +GRAPH_TYPE_SITE = 300 +GRAPH_TYPE_CHOICES = ( + (GRAPH_TYPE_INTERFACE, 'Interface'), + (GRAPH_TYPE_PROVIDER, 'Provider'), + (GRAPH_TYPE_SITE, 'Site'), +) + +EXPORTTEMPLATE_MODELS = [ + 'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', + 'aggregate', 'prefix', 'ipaddress', 'vlan', + 'provider', 'circuit' +] + + +class Graph(models.Model): + type = models.PositiveSmallIntegerField(choices=GRAPH_TYPE_CHOICES) + weight = models.PositiveSmallIntegerField(default=1000) + name = models.CharField(max_length=100, verbose_name='Name') + source = models.CharField(max_length=500, verbose_name='Source URL') + link = models.URLField(verbose_name='Link URL', blank=True) + + class Meta: + ordering = ['type', 'weight', 'name'] + + def __unicode__(self): + return self.name + + def embed_url(self, obj): + template = Template(self.source) + return template.render(Context({'obj': obj})) + + +class ExportTemplate(models.Model): + content_type = models.ForeignKey(ContentType, limit_choices_to={'model__in': EXPORTTEMPLATE_MODELS}) + name = models.CharField(max_length=200) + template_code = models.TextField() + mime_type = models.CharField(max_length=15, blank=True) + file_extension = models.CharField(max_length=15, blank=True) + + class Meta: + ordering = ['content_type', 'name'] + unique_together = [ + ['content_type', 'name'] + ] + + def __unicode__(self): + return "{}: {}".format(self.content_type, self.name) + + def to_response(self, context_dict, filename): + """ + Render the template to an HTTP response, delivered as a named file attachment + """ + template = Template(self.template_code) + mime_type = 'text/plain' if not self.mime_type else self.mime_type + response = HttpResponse( + template.render(Context(context_dict)), + content_type=mime_type + ) + if self.file_extension: + filename += '.{}'.format(self.file_extension) + response['Content-Disposition'] = 'attachment; filename="{}"'.format(filename) + return response diff --git a/netbox/extras/rpc.py b/netbox/extras/rpc.py new file mode 100644 index 000000000..b8fa798f6 --- /dev/null +++ b/netbox/extras/rpc.py @@ -0,0 +1,247 @@ +from Exscript import Account +from Exscript.protocols import SSH2 +from ncclient import manager +import paramiko +import re +import xmltodict + + +CONNECT_TIMEOUT = 5 # seconds + + +class RPCClient(object): + + def __init__(self, device, username='', password=''): + self.username = username + self.password = password + try: + self.host = str(device.primary_ip.address.ip) + except AttributeError: + raise Exception("Specified device ({}) does not have a primary IP defined.".format(device)) + + def get_lldp_neighbors(self): + """ + Returns a list of dictionaries, each representing an LLDP neighbor adjacency. + + { + 'local-interface': , + 'name': , + 'remote-interface': , + 'chassis-id': , + } + """ + raise NotImplementedError("Feature not implemented for this platform.") + + def get_inventory(self): + """ + Returns a dictionary representing the device chassis and installed modules. + + { + 'chassis': { + 'serial': , + 'description': , + } + 'modules': [ + { + 'name': , + 'part_id': , + 'serial': , + }, + ... + ] + } + """ + raise NotImplementedError("Feature not implemented for this platform.") + + +class JunosNC(RPCClient): + """ + NETCONF client for Juniper Junos devices + """ + + def __enter__(self): + + # Initiate a connection to the device + self.manager = manager.connect(host=self.host, username=self.username, password=self.password, + hostkey_verify=False, timeout=CONNECT_TIMEOUT) + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + + # Close the connection to the device + self.manager.close_session() + + def get_lldp_neighbors(self): + + rpc_reply = self.manager.dispatch('get-lldp-neighbors-information') + lldp_neighbors_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['lldp-neighbors-information']['lldp-neighbor-information'] + + result = [] + for neighbor_raw in lldp_neighbors_raw: + neighbor = dict() + neighbor['local-interface'] = neighbor_raw.get('lldp-local-port-id') + neighbor['name'] = neighbor_raw.get('lldp-remote-system-name') + neighbor['name'] = neighbor['name'].split('.')[0] # Split hostname from domain if one is present + try: + neighbor['remote-interface'] = neighbor_raw['lldp-remote-port-description'] + except KeyError: + # Older versions of Junos report on interface ID instead of description + neighbor['remote-interface'] = neighbor_raw.get('lldp-remote-port-id') + neighbor['chassis-id'] = neighbor_raw.get('lldp-remote-chassis-id') + result.append(neighbor) + + return result + + def get_inventory(self): + + rpc_reply = self.manager.dispatch('get-chassis-inventory') + inventory_raw = xmltodict.parse(rpc_reply.xml)['rpc-reply']['chassis-inventory']['chassis'] + + result = dict() + + # Gather chassis data + result['chassis'] = { + 'serial': inventory_raw['serial-number'], + 'description': inventory_raw['description'], + } + + # Gather modules + result['modules'] = [] + for module in inventory_raw['chassis-module']: + try: + # Skip built-in modules + if module['name'] and module['serial-number'] != inventory_raw['serial-number']: + result['modules'].append({ + 'name': module['name'], + 'part_id': module['model-number'] or '', + 'serial': module['serial-number'] or '', + }) + except KeyError: + pass + + return result + + +class IOSSSH(RPCClient): + """ + SSH client for Cisco IOS devices + """ + + def __enter__(self): + + # Initiate a connection to the device + self.ssh = SSH2(connect_timeout=CONNECT_TIMEOUT) + self.ssh.connect(self.host) + self.ssh.login(Account(self.username, self.password)) + + # Disable terminal paging + self.ssh.execute("terminal length 0") + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + + # Close the connection to the device + self.ssh.send("exit\r") + self.ssh.close() + + def get_inventory(self): + + result = dict() + + # Gather chassis data + try: + self.ssh.execute("show version") + show_version = self.ssh.response + serial = re.search("Processor board ID ([^\s]+)", show_version).groups()[0] + description = re.search("\r\n\r\ncisco ([^\s]+)", show_version).groups()[0] + except: + raise RuntimeError("Failed to glean chassis info from device.") + result['chassis'] = { + 'serial': serial, + 'description': description, + } + + # Gather modules + result['modules'] = [] + try: + self.ssh.execute("show inventory") + show_inventory = self.ssh.response + # Split modules on double line + modules_raw = show_inventory.strip().split('\r\n\r\n') + for module_raw in modules_raw: + try: + m_name = re.search('NAME: "([^"]+)"', module_raw).group(1) + m_pid = re.search('PID: ([^\s]+)', module_raw).group(1) + m_serial = re.search('SN: ([^\s]+)', module_raw).group(1) + # Omit built-in modules and those with no PID + if m_serial != result['chassis']['serial'] and m_pid.lower() != 'unspecified': + result['modules'].append({ + 'name': m_name, + 'part_id': m_pid, + 'serial': m_serial, + }) + except AttributeError: + continue + except: + raise RuntimeError("Failed to glean module info from device.") + + return result + + +class OpengearSSH(RPCClient): + """ + SSH client for Opengear devices + """ + + def __enter__(self): + + # Initiate a connection to the device + self.ssh = paramiko.SSHClient() + self.ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + try: + self.ssh.connect(self.host, username=self.username, password=self.password, timeout=CONNECT_TIMEOUT) + except paramiko.AuthenticationException: + # Try default Opengear credentials if the configured creds don't work + self.ssh.connect(self.host, username='root', password='default') + + return self + + def __exit__(self, exc_type, exc_val, exc_tb): + + # Close the connection to the device + self.ssh.close() + + def get_inventory(self): + + try: + stdin, stdout, stderr = self.ssh.exec_command("showserial") + serial = stdout.readlines()[0].strip() + except: + raise RuntimeError("Failed to glean chassis serial from device.") + # Older models don't provide serial info + if serial == "No serial number information available": + serial = '' + + try: + stdin, stdout, stderr = self.ssh.exec_command("config -g config.system.model") + description = stdout.readlines()[0].split(' ', 1)[1].strip() + except: + raise RuntimeError("Failed to glean chassis description from device.") + + return { + 'chassis': { + 'serial': serial, + 'description': description, + }, + 'modules': [], + } + + +# For mapping platform -> NC client +RPC_CLIENTS = { + 'juniper-junos': JunosNC, + 'cisco-ios': IOSSSH, + 'opengear': OpengearSSH, +} diff --git a/netbox/extras/tests.py b/netbox/extras/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/netbox/extras/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/netbox/extras/views.py b/netbox/extras/views.py new file mode 100644 index 000000000..91ea44a21 --- /dev/null +++ b/netbox/extras/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. diff --git a/netbox/ipam/__init__.py b/netbox/ipam/__init__.py new file mode 100644 index 000000000..43fdadae5 --- /dev/null +++ b/netbox/ipam/__init__.py @@ -0,0 +1,2 @@ +default_app_config = 'ipam.apps.IPAMConfig' + diff --git a/netbox/ipam/admin.py b/netbox/ipam/admin.py new file mode 100644 index 000000000..e3d4315dc --- /dev/null +++ b/netbox/ipam/admin.py @@ -0,0 +1,74 @@ +from django.contrib import admin + +from .models import * + + +@admin.register(VRF) +class VRFAdmin(admin.ModelAdmin): + list_display = ['name', 'rd'] + + +@admin.register(Status) +class StatusAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug', 'weight', 'bootstrap_class'] + + +@admin.register(Role) +class RoleAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug', 'weight'] + + +@admin.register(RIR) +class RIRAdmin(admin.ModelAdmin): + prepopulated_fields = { + 'slug': ['name'], + } + list_display = ['name', 'slug'] + + +@admin.register(Aggregate) +class AggregateAdmin(admin.ModelAdmin): + list_display = ['prefix', 'rir', 'date_added'] + list_filter = ['family', 'rir'] + search_fields = ['prefix'] + + +@admin.register(Prefix) +class PrefixAdmin(admin.ModelAdmin): + list_display = ['prefix', 'vrf', 'site', 'status', 'role', 'vlan'] + list_filter = ['family', 'site', 'status', 'role'] + search_fields = ['prefix'] + + def get_queryset(self, request): + qs = super(PrefixAdmin, self).get_queryset(request) + return qs.select_related('vrf', 'site', 'status', 'role', 'vlan') + + +@admin.register(IPAddress) +class IPAddressAdmin(admin.ModelAdmin): + list_display = ['address', 'vrf', 'nat_inside'] + list_filter = ['family'] + fields = ['address', 'vrf', 'device', 'interface', 'nat_inside'] + readonly_fields = ['interface', 'device', 'nat_inside'] + search_fields = ['address'] + + def get_queryset(self, request): + qs = super(IPAddressAdmin, self).get_queryset(request) + return qs.select_related('vrf', 'nat_inside') + + +@admin.register(VLAN) +class VLANAdmin(admin.ModelAdmin): + list_display = ['site', 'vid', 'name', 'status', 'role'] + list_filter = ['site', 'status', 'role'] + search_fields = ['vid', 'name'] + + def get_queryset(self, request): + qs = super(VLANAdmin, self).get_queryset(request) + return qs.select_related('site', 'status', 'role') diff --git a/netbox/ipam/api/__init__.py b/netbox/ipam/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py new file mode 100644 index 000000000..3e94463b9 --- /dev/null +++ b/netbox/ipam/api/serializers.py @@ -0,0 +1,158 @@ +from rest_framework import serializers + +from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from ipam.models import VRF, Status, Role, RIR, Aggregate, Prefix, IPAddress, VLAN + + +# +# VRFs +# + +class VRFSerializer(serializers.ModelSerializer): + + class Meta: + model = VRF + fields = ['id', 'name', 'rd', 'description'] + + +class VRFNestedSerializer(VRFSerializer): + + class Meta(VRFSerializer.Meta): + fields = ['id', 'name', 'rd'] + + +# +# Statuses +# + +class StatusSerializer(serializers.ModelSerializer): + + class Meta: + model = Status + fields = ['id', 'name', 'slug', 'weight', 'bootstrap_class'] + + +class StatusNestedSerializer(StatusSerializer): + + class Meta(StatusSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# Roles +# + +class RoleSerializer(serializers.ModelSerializer): + + class Meta: + model = Role + fields = ['id', 'name', 'slug', 'weight'] + + +class RoleNestedSerializer(RoleSerializer): + + class Meta(RoleSerializer.Meta): + fields = ['id', 'name', 'slug'] + + +# +# RIRs +# + +class RIRSerializer(serializers.ModelSerializer): + + class Meta: + model = RIR + fields = ['id', 'name', 'slug'] + + +class RIRNestedSerializer(RIRSerializer): + + class Meta(RIRSerializer.Meta): + pass + + +# +# Aggregates +# + +class AggregateSerializer(serializers.ModelSerializer): + rir = RIRNestedSerializer() + + class Meta: + model = Aggregate + fields = ['id', 'family', 'prefix', 'rir', 'date_added', 'description'] + + +class AggregateNestedSerializer(AggregateSerializer): + + class Meta(AggregateSerializer.Meta): + fields = ['id', 'family', 'prefix'] + + +# +# VLANs +# + +class VLANSerializer(serializers.ModelSerializer): + display_name = serializers.SerializerMethodField() + site = SiteNestedSerializer() + status = StatusNestedSerializer() + role = RoleNestedSerializer() + + class Meta: + model = VLAN + fields = ['id', 'site', 'vid', 'name', 'status', 'role', 'display_name'] + + def get_display_name(self, obj): + return "{} ({})".format(obj.vid, obj.name) + + +class VLANNestedSerializer(VLANSerializer): + + class Meta(VLANSerializer.Meta): + fields = ['id', 'vid', 'name', 'display_name'] + + +# +# Prefixes +# + +class PrefixSerializer(serializers.ModelSerializer): + site = SiteNestedSerializer() + vrf = VRFNestedSerializer() + vlan = VLANNestedSerializer() + status = StatusNestedSerializer() + role = RoleNestedSerializer() + + class Meta: + model = Prefix + fields = ['id', 'family', 'prefix', 'site', 'vrf', 'vlan', 'status', 'role', 'description'] + + +class PrefixNestedSerializer(PrefixSerializer): + + class Meta(PrefixSerializer.Meta): + fields = ['id', 'family', 'prefix'] + + +# +# IP addresses +# + +class IPAddressSerializer(serializers.ModelSerializer): + vrf = VRFNestedSerializer() + interface = InterfaceNestedSerializer() + + class Meta: + model = IPAddress + fields = ['id', 'family', 'address', 'vrf', 'interface', 'description', 'nat_inside', 'nat_outside'] + + +class IPAddressNestedSerializer(IPAddressSerializer): + + class Meta(IPAddressSerializer.Meta): + fields = ['id', 'family', 'address'] + +IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() +IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer() diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py new file mode 100644 index 000000000..d1ad6c7d1 --- /dev/null +++ b/netbox/ipam/api/urls.py @@ -0,0 +1,40 @@ +from django.conf.urls import url + +from .views import * + + +urlpatterns = [ + + # VRFs + url(r'^vrfs/$', VRFListView.as_view(), name='vrf_list'), + url(r'^vrfs/(?P\d+)/$', VRFDetailView.as_view(), name='vrf_detail'), + + # Statuses + url(r'^statuses/$', StatusListView.as_view(), name='status_list'), + url(r'^statuses/(?P\d+)/$', StatusDetailView.as_view(), name='status_detail'), + + # Roles + url(r'^roles/$', RoleListView.as_view(), name='role_list'), + url(r'^roles/(?P\d+)/$', RoleDetailView.as_view(), name='role_detail'), + + # RIRs + url(r'^rirs/$', RIRListView.as_view(), name='rir_list'), + url(r'^rirs/(?P\d+)/$', RIRDetailView.as_view(), name='rir_detail'), + + # Aggregates + url(r'^aggregates/$', AggregateListView.as_view(), name='aggregate_list'), + url(r'^aggregates/(?P\d+)/$', AggregateDetailView.as_view(), name='aggregate_detail'), + + # Prefixes + url(r'^prefixes/$', PrefixListView.as_view(), name='prefix_list'), + url(r'^prefixes/(?P\d+)/$', PrefixDetailView.as_view(), name='prefix_detail'), + + # IP addresses + url(r'^ip-addresses/$', IPAddressListView.as_view(), name='ipaddress_list'), + url(r'^ip-addresses/(?P\d+)/$', IPAddressDetailView.as_view(), name='ipaddress_detail'), + + # VLANs + url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), + url(r'^vlans/(?P\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), + +] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py new file mode 100644 index 000000000..0fdf53de5 --- /dev/null +++ b/netbox/ipam/api/views.py @@ -0,0 +1,140 @@ +from rest_framework import generics + +from ipam.models import VRF, Status, Role, RIR, Aggregate, Prefix, IPAddress, VLAN +from ipam.filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter +from .serializers import VRFSerializer, StatusSerializer, RoleSerializer, RIRSerializer, AggregateSerializer, \ + PrefixSerializer, IPAddressSerializer, VLANSerializer + + +class VRFListView(generics.ListAPIView): + """ + List all VRFs + """ + queryset = VRF.objects.all() + serializer_class = VRFSerializer + + +class VRFDetailView(generics.RetrieveAPIView): + """ + Retrieve a single VRF + """ + queryset = VRF.objects.all() + serializer_class = VRFSerializer + + +class StatusListView(generics.ListAPIView): + """ + List all statuses + """ + queryset = Status.objects.all() + serializer_class = StatusSerializer + + +class StatusDetailView(generics.RetrieveAPIView): + """ + Retrieve a single status + """ + queryset = Status.objects.all() + serializer_class = StatusSerializer + + +class RoleListView(generics.ListAPIView): + """ + List all roles + """ + queryset = Role.objects.all() + serializer_class = RoleSerializer + + +class RoleDetailView(generics.RetrieveAPIView): + """ + Retrieve a single role + """ + queryset = Role.objects.all() + serializer_class = RoleSerializer + + +class RIRListView(generics.ListAPIView): + """ + List all RIRs + """ + queryset = RIR.objects.all() + serializer_class = RIRSerializer + + +class RIRDetailView(generics.RetrieveAPIView): + """ + Retrieve a single RIR + """ + queryset = RIR.objects.all() + serializer_class = RIRSerializer + + +class AggregateListView(generics.ListAPIView): + """ + List aggregates (filterable) + """ + queryset = Aggregate.objects.select_related('rir') + serializer_class = AggregateSerializer + filter_class = AggregateFilter + + +class AggregateDetailView(generics.RetrieveAPIView): + """ + Retrieve a single aggregate + """ + queryset = Aggregate.objects.select_related('rir') + serializer_class = AggregateSerializer + + +class PrefixListView(generics.ListAPIView): + """ + List prefixes (filterable) + """ + queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'status', 'role') + serializer_class = PrefixSerializer + filter_class = PrefixFilter + + +class PrefixDetailView(generics.RetrieveAPIView): + """ + Retrieve a single prefix + """ + queryset = Prefix.objects.select_related('site', 'vrf', 'vlan', 'status', 'role') + serializer_class = PrefixSerializer + + +class IPAddressListView(generics.ListAPIView): + """ + List IP addresses (filterable) + """ + queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\ + .prefetch_related('nat_outside') + serializer_class = IPAddressSerializer + filter_class = IPAddressFilter + + +class IPAddressDetailView(generics.RetrieveAPIView): + """ + Retrieve a single IP address + """ + queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'nat_inside')\ + .prefetch_related('nat_outside') + serializer_class = IPAddressSerializer + + +class VLANListView(generics.ListAPIView): + """ + List VLANs (filterable) + """ + queryset = VLAN.objects.select_related('site', 'status', 'role') + serializer_class = VLANSerializer + filter_class = VLANFilter + + +class VLANDetailView(generics.RetrieveAPIView): + """ + Retrieve a single VLAN + """ + queryset = VLAN.objects.select_related('site', 'status', 'role') + serializer_class = VLANSerializer diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py new file mode 100644 index 000000000..fd4af74b0 --- /dev/null +++ b/netbox/ipam/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class IPAMConfig(AppConfig): + name = "ipam" + verbose_name = "IPAM" diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py new file mode 100644 index 000000000..ee0ddaa83 --- /dev/null +++ b/netbox/ipam/fields.py @@ -0,0 +1,82 @@ +from netaddr import IPNetwork + +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.six import with_metaclass + +from .formfields import IPFormField +from .lookups import EndsWith, IEndsWith, StartsWith, IStartsWith, Regex, IRegex, NetContained, NetContainedOrEqual, \ + NetContains, NetContainsOrEquals, NetHost + + +class _BaseIPField(models.Field): + + def python_type(self): + return IPNetwork + + def to_python(self, value): + if not value: + return value + try: + return IPNetwork(value) + except ValueError as e: + raise ValidationError(e) + + def get_prep_value(self, value): + if not value: + return None + return str(self.to_python(value)) + + def form_class(self): + return IPFormField + + def formfield(self, **kwargs): + defaults = {'form_class': self.form_class()} + defaults.update(kwargs) + return super(_BaseIPField, self).formfield(**defaults) + + +class IPNetworkField(with_metaclass(models.SubfieldBase, _BaseIPField)): + """ + IP prefix (network and mask) + """ + description = "PostgreSQL CIDR field" + + def db_type(self, connection): + return 'cidr' + + +IPNetworkField.register_lookup(EndsWith) +IPNetworkField.register_lookup(IEndsWith) +IPNetworkField.register_lookup(StartsWith) +IPNetworkField.register_lookup(IStartsWith) +IPNetworkField.register_lookup(Regex) +IPNetworkField.register_lookup(IRegex) +IPNetworkField.register_lookup(NetContained) +IPNetworkField.register_lookup(NetContainedOrEqual) +IPNetworkField.register_lookup(NetContains) +IPNetworkField.register_lookup(NetContainsOrEquals) +IPNetworkField.register_lookup(NetHost) + + +class IPAddressField(with_metaclass(models.SubfieldBase, _BaseIPField)): + """ + IP address (host address and mask) + """ + description = "PostgreSQL INET field" + + def db_type(self, connection): + return 'inet' + + +IPAddressField.register_lookup(EndsWith) +IPAddressField.register_lookup(IEndsWith) +IPAddressField.register_lookup(StartsWith) +IPAddressField.register_lookup(IStartsWith) +IPAddressField.register_lookup(Regex) +IPAddressField.register_lookup(IRegex) +IPAddressField.register_lookup(NetContained) +IPAddressField.register_lookup(NetContainedOrEqual) +IPAddressField.register_lookup(NetContains) +IPAddressField.register_lookup(NetContainsOrEquals) +IPAddressField.register_lookup(NetHost) diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py new file mode 100644 index 000000000..412f0b8d2 --- /dev/null +++ b/netbox/ipam/filters.py @@ -0,0 +1,216 @@ +import django_filters +from netaddr import IPNetwork +from netaddr.core import AddrFormatError + +from dcim.models import Site, Device, Interface +from ipam.models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, Status, Role + + +class VRFFilter(django_filters.FilterSet): + name = django_filters.CharFilter( + name='name', + lookup_type='icontains', + label='Name', + ) + + class Meta: + model = VRF + fields = ['name', 'rd'] + + +class AggregateFilter(django_filters.FilterSet): + rir_id = django_filters.ModelMultipleChoiceFilter( + name='rir', + queryset=RIR.objects.all(), + label='RIR (ID)', + ) + rir = django_filters.ModelMultipleChoiceFilter( + name='rir', + queryset=RIR.objects.all(), + to_field_name='slug', + label='RIR (slug)', + ) + + class Meta: + model = Aggregate + fields = ['family', 'rir_id', 'rir', 'date_added'] + + +class PrefixFilter(django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) + parent = django_filters.MethodFilter( + action='search_by_parent', + label='Parent prefix', + ) + vrf_id = django_filters.MethodFilter( + action='vrf', + label='VRF (ID)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + vlan_id = django_filters.ModelMultipleChoiceFilter( + name='vlan', + queryset=VLAN.objects.all(), + label='VLAN (ID)', + ) + vlan_vid = django_filters.NumberFilter( + name='vlan__vid', + label='VLAN number (1-4095)', + ) + status_id = django_filters.ModelMultipleChoiceFilter( + name='status', + queryset=Status.objects.all(), + label='Status (ID)', + ) + status = django_filters.ModelMultipleChoiceFilter( + name='status', + queryset=Status.objects.all(), + to_field_name='slug', + label='Status (slug)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=Role.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=Role.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + + class Meta: + model = Prefix + fields = ['family', 'site_id', 'site', 'vrf_id', 'vrf', 'vlan_id', 'vlan_vid', 'status_id', 'status', 'role_id', + 'role'] + + def search(self, queryset, value): + value = value.strip() + try: + query = str(IPNetwork(value).cidr) + return queryset.filter(prefix__net_contains_or_equals=query) + except AddrFormatError: + return queryset.none() + + def search_by_parent(self, queryset, value): + value = value.strip() + if not value: + return queryset + try: + query = str(IPNetwork(value).cidr) + return queryset.filter(prefix__net_contained_or_equal=query) + except AddrFormatError: + return queryset.none() + + def vrf(self, queryset, value): + if str(value) == '': + return queryset + try: + vrf_id = int(value) + except ValueError: + return queryset.none() + if vrf_id == 0: + return queryset.filter(vrf__isnull=True) + return queryset.filter(vrf__pk=value) + + +class IPAddressFilter(django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) + vrf_id = django_filters.ModelMultipleChoiceFilter( + name='vrf', + queryset=VRF.objects.all(), + label='VRF (ID)', + ) + device_id = django_filters.ModelMultipleChoiceFilter( + name='interface__device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='interface__device', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + interface_id = django_filters.ModelMultipleChoiceFilter( + name='interface', + queryset=Interface.objects.all(), + label='Interface (ID)', + ) + + class Meta: + model = IPAddress + fields = ['q', 'family', 'vrf_id', 'vrf', 'device_id', 'device', 'interface_id'] + + def search(self, queryset, value): + value = value.strip() + try: + query = str(IPNetwork(value)) + return queryset.filter(address__net_host=query) + except AddrFormatError: + return queryset.none() + + +class VLANFilter(django_filters.FilterSet): + site_id = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + name='site', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site (slug)', + ) + name = django_filters.CharFilter( + name='name', + lookup_type='icontains', + label='Name', + ) + vid = django_filters.NumberFilter( + name='vid', + label='VLAN number (1-4095)', + ) + status_id = django_filters.ModelMultipleChoiceFilter( + name='status', + queryset=Status.objects.all(), + label='Status (ID)', + ) + status = django_filters.ModelMultipleChoiceFilter( + name='status', + queryset=Status.objects.all(), + to_field_name='slug', + label='Status (slug)', + ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=Role.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=Role.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + + class Meta: + model = VLAN + fields = ['site_id', 'site', 'vid', 'name', 'status_id', 'status', 'role_id', 'role'] diff --git a/netbox/ipam/fixtures/ipam.yaml b/netbox/ipam/fixtures/ipam.yaml new file mode 100644 index 000000000..d6bb428d6 --- /dev/null +++ b/netbox/ipam/fixtures/ipam.yaml @@ -0,0 +1,98 @@ +- model: ipam.status + pk: 1 + fields: {name: Active, slug: active, weight: 1000, bootstrap_class: 1} +- model: ipam.status + pk: 2 + fields: {name: Inactive, slug: inactive, weight: 500, bootstrap_class: 3} +- model: ipam.role + pk: 1 + fields: {name: Lab Network, slug: lab-network, weight: 1000} +- model: ipam.rir + pk: 1 + fields: {name: RFC1918, slug: rfc1918} +- model: ipam.aggregate + pk: 1 + fields: {family: 4, prefix: 10.0.0.0/8, rir: 1, date_added: 2016-01-01, description: ''} +- model: ipam.prefix + pk: 1 + fields: {family: 4, prefix: 10.1.1.0/24, site: 1, vrf: null, vlan: null, status: 1, + role: 1, description: ''} +- model: ipam.prefix + pk: 2 + fields: {family: 4, prefix: 10.0.255.0/24, site: 1, vrf: null, vlan: null, status: 1, + role: 1, description: ''} +- model: ipam.ipaddress + pk: 1 + fields: {family: 4, address: 10.0.255.1/32, vrf: null, interface: 3, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 2 + fields: {family: 4, address: 169.254.254.1/31, vrf: null, interface: 4, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 3 + fields: {family: 4, address: 10.0.255.2/32, vrf: null, interface: 185, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 4 + fields: {family: 4, address: 169.254.1.1/31, vrf: null, interface: 213, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 5 + fields: {family: 4, address: 10.0.254.1/24, vrf: null, interface: 12, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 8 + fields: {family: 4, address: 10.15.21.1/31, vrf: null, interface: 218, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 9 + fields: {family: 4, address: 10.15.21.2/31, vrf: null, interface: 9, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 10 + fields: {family: 4, address: 10.15.22.1/31, vrf: null, interface: 8, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 11 + fields: {family: 4, address: 10.15.20.1/31, vrf: null, interface: 7, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 12 + fields: {family: 4, address: 10.16.20.1/31, vrf: null, interface: 216, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 13 + fields: {family: 4, address: 10.15.22.2/31, vrf: null, interface: 206, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 14 + fields: {family: 4, address: 10.16.22.1/31, vrf: null, interface: 217, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 15 + fields: {family: 4, address: 10.16.22.2/31, vrf: null, interface: 205, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 16 + fields: {family: 4, address: 10.16.20.2/31, vrf: null, interface: 211, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 17 + fields: {family: 4, address: 10.15.22.2/31, vrf: null, interface: 212, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 19 + fields: {family: 4, address: 10.0.254.2/32, vrf: null, interface: 188, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 20 + fields: {family: 4, address: 169.254.1.1/31, vrf: null, interface: 200, nat_inside: null, + description: ''} +- model: ipam.ipaddress + pk: 21 + fields: {family: 4, address: 169.254.1.2/31, vrf: null, interface: 194, nat_inside: null, + description: ''} +- model: ipam.vlan + pk: 1 + fields: {site: 1, vid: 999, name: TEST, status: 1, role: 1} diff --git a/netbox/ipam/formfields.py b/netbox/ipam/formfields.py new file mode 100644 index 000000000..914310be9 --- /dev/null +++ b/netbox/ipam/formfields.py @@ -0,0 +1,30 @@ +from netaddr import IPNetwork, AddrFormatError + +from django import forms +from django.core.exceptions import ValidationError + + +# +# Form fields +# + +class IPFormField(forms.Field): + default_error_messages = { + 'invalid': "Enter a valid IPv4 or IPv6 address (with CIDR mask).", + } + + def to_python(self, value): + if not value: + return None + + if isinstance(value, IPNetwork): + return value + + # Ensure that a subnet mask has been specified. This prevents IPs from defaulting to a /32 or /128. + if len(value.split('/')) != 2: + raise ValidationError('CIDR mask (e.g. /24) is required.') + + try: + return IPNetwork(value) + except AddrFormatError: + raise ValidationError("Please specify a valid IPv4 or IPv6 address.") diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py new file mode 100644 index 000000000..f0952debf --- /dev/null +++ b/netbox/ipam/forms.py @@ -0,0 +1,408 @@ +from netaddr import IPNetwork + +from django import forms +from django.db.models import Count + +from dcim.models import Site, Device, Interface +from utilities.forms import BootstrapMixin, ConfirmationForm, APISelect, Livesearch, CSVDataField, BulkImportForm +from .models import VRF, RIR, Aggregate, Prefix, IPAddress, VLAN, Status, Role + + +# +# VRFs +# + +class VRFForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = VRF + fields = ['name', 'rd', 'description'] + help_texts = { + 'rd': "Route distinguisher in any format", + } + + +class VRFFromCSVForm(forms.ModelForm): + + class Meta: + model = VRF + fields = ['name', 'rd', 'description'] + + +class VRFImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=VRFFromCSVForm) + + +class VRFBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) + description = forms.CharField(max_length=100, required=False) + + +class VRFBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=VRF.objects.all(), widget=forms.MultipleHiddenInput) + + +# +# Aggregates +# + +class AggregateForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = Aggregate + fields = ['prefix', 'rir', 'date_added', 'description'] + help_texts = { + 'prefix': "IPv4 or IPv6 network", + 'rir': "Regional Internet Registry responsible for this prefix", + 'date_added': "Format: YYYY-MM-DD", + } + + +class AggregateFromCSVForm(forms.ModelForm): + rir = forms.ModelChoiceField(queryset=RIR.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'RIR not found.'}) + + class Meta: + model = Aggregate + fields = ['prefix', 'rir', 'date_added', 'description'] + + +class AggregateImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=AggregateFromCSVForm) + + +class AggregateBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) + rir = forms.ModelChoiceField(queryset=RIR.objects.all(), required=False, label='RIR') + date_added = forms.DateField(required=False) + description = forms.CharField(max_length=50, required=False) + + +class AggregateBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Aggregate.objects.all(), widget=forms.MultipleHiddenInput) + + +def aggregate_rir_choices(): + rir_choices = RIR.objects.annotate(aggregate_count=Count('aggregates')) + return [(r.slug, '{} ({})'.format(r.name, r.aggregate_count)) for r in rir_choices] + + +class AggregateFilterForm(forms.Form, BootstrapMixin): + rir = forms.MultipleChoiceField(required=False, choices=aggregate_rir_choices, label='RIR', + widget=forms.SelectMultiple(attrs={'size': 8})) + + +# +# Prefixes +# + +class PrefixForm(forms.ModelForm, BootstrapMixin): + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', + widget=forms.Select(attrs={'filter-for': 'vlan'})) + vlan = forms.ModelChoiceField(queryset=VLAN.objects.all(), required=False, label='VLAN', + widget=APISelect(api_url='/api/ipam/vlans/?site_id={{site}}')) + + class Meta: + model = Prefix + fields = ['prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'description'] + help_texts = { + 'prefix': "IPv4 or IPv6 network", + 'vrf': "VRF (if applicable)", + 'site': "The site to which this prefix is assigned (if applicable)", + 'vlan': "The VLAN to which this prefix is assigned (if applicable)", + 'status': "Operational status of this prefix", + 'role': "The primary function of this prefix", + } + + def __init__(self, *args, **kwargs): + super(PrefixForm, self).__init__(*args, **kwargs) + + self.fields['vrf'].empty_label = 'Global' + + # Initialize field without choices to avoid pulling all VLANs from the database + if self.is_bound and self.data.get('site'): + self.fields['vlan'].queryset = VLAN.objects.filter(site__pk=self.data['site']) + elif self.initial.get('site'): + self.fields['vlan'].queryset = VLAN.objects.filter(site=self.initial['site']) + else: + self.fields['vlan'].choices = [] + + def clean_prefix(self): + data = self.cleaned_data['prefix'] + try: + prefix = IPNetwork(data) + except: + raise + if prefix.version == 4 and prefix.prefixlen == 32: + raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 " + "addresses instead.") + elif prefix.version == 6 and prefix.prefixlen == 128: + raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 " + "addresses instead.") + return data + + +class PrefixFromCSVForm(forms.ModelForm): + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', + error_messages={'invalid_choice': 'VRF not found.'}) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Site not found.'}) + status = forms.ModelChoiceField(queryset=Status.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid status.'}) + role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Invalid role.'}) + + class Meta: + model = Prefix + fields = ['prefix', 'vrf', 'site', 'status', 'role', 'description'] + + +class PrefixImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=PrefixFromCSVForm) + + +class PrefixBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', + help_text="Select the VRF to assign, or check below to remove VRF assignment") + vrf_global = forms.BooleanField(required=False, label='Set VRF to global') + status = forms.ModelChoiceField(queryset=Status.objects.all(), required=False) + role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) + description = forms.CharField(max_length=50, required=False) + + +class PrefixBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Prefix.objects.all(), widget=forms.MultipleHiddenInput) + + +def prefix_vrf_choices(): + vrf_choices = [('', 'All'), (0, 'Global')] + vrf_choices += [(v.pk, v.name) for v in VRF.objects.all()] + return vrf_choices + + +def prefix_status_choices(): + status_choices = Status.objects.annotate(prefix_count=Count('prefixes')) + return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in status_choices] + + +def prefix_site_choices(): + site_choices = Site.objects.annotate(prefix_count=Count('prefixes')) + return [(s.slug, '{} ({})'.format(s.name, s.prefix_count)) for s in site_choices] + + +def prefix_role_choices(): + role_choices = Role.objects.annotate(prefix_count=Count('prefixes')) + return [(r.slug, '{} ({})'.format(r.name, r.prefix_count)) for r in role_choices] + + +class PrefixFilterForm(forms.Form, BootstrapMixin): + parent = forms.CharField(required=False, label='Search Within') + vrf = forms.ChoiceField(required=False, choices=prefix_vrf_choices, label='VRF') + status = forms.MultipleChoiceField(required=False, choices=prefix_status_choices) + site = forms.MultipleChoiceField(required=False, choices=prefix_site_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + role = forms.MultipleChoiceField(required=False, choices=prefix_role_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + expand = forms.BooleanField(required=False, label='Expand prefix hierarchy') + + +# +# IP addresses +# + +class IPAddressForm(forms.ModelForm, BootstrapMixin): + nat_site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site', + widget=forms.Select(attrs={'filter-for': 'nat_device'})) + nat_device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, label='Device', + widget=APISelect(api_url='/api/dcim/devices/?site_id={{nat_site}}', + attrs={'filter-for': 'nat_inside'})) + livesearch = forms.CharField(required=False, label='IP Address', widget=Livesearch( + query_key='q', query_url='ipam-api:ipaddress_list', field_to_update='nat_inside', obj_label='address') + ) + nat_inside = forms.ModelChoiceField(queryset=IPAddress.objects.all(), required=False, label='NAT (Inside)', + widget=APISelect(api_url='/api/ipam/ip-addresses/?device_id={{nat_device}}', + display_field='address')) + + class Meta: + model = IPAddress + fields = ['address', 'vrf', 'nat_device', 'nat_inside', 'description'] + help_texts = { + 'address': "IPv4 or IPv6 address and mask", + 'vrf': "VRF (if applicable)", + } + + def __init__(self, *args, **kwargs): + super(IPAddressForm, self).__init__(*args, **kwargs) + + self.fields['vrf'].empty_label = 'Global' + + if self.instance.nat_inside: + + nat_inside = self.instance.nat_inside + # If the IP is assigned to an interface, populate site/device fields accordingly + if self.instance.nat_inside.interface: + self.initial['nat_site'] = self.instance.nat_inside.interface.device.rack.site.pk + self.initial['nat_device'] = self.instance.nat_inside.interface.device.pk + self.fields['nat_device'].queryset = Device.objects.filter(rack__site=nat_inside.interface.device.rack.site) + self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device=nat_inside.interface.device) + else: + self.fields['nat_inside'].queryset = IPAddress.objects.filter(pk=nat_inside.pk) + + else: + + # Initialize nat_device choices if nat_site is set + if self.is_bound and self.data.get('nat_site'): + self.fields['nat_device'].queryset = Device.objects.filter(rack__site__pk=self.data['nat_site']) + elif self.initial.get('nat_site'): + self.fields['nat_device'].queryset = Device.objects.filter(rack__site=self.initial['nat_site']) + else: + self.fields['nat_device'].choices = [] + + # Initialize nat_inside choices if nat_device is set + if self.is_bound and self.data.get('nat_device'): + self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device__pk=self.data['nat_device']) + elif self.initial.get('nat_device'): + self.fields['nat_inside'].queryset = IPAddress.objects.filter(interface__device__pk=self.initial['nat_device']) + else: + self.fields['nat_inside'].choices = [] + + +class IPAddressFromCSVForm(forms.ModelForm): + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, to_field_name='rd', + error_messages={'invalid_choice': 'Site not found.'}) + device = forms.ModelChoiceField(queryset=Device.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Device not found.'}) + interface_name = forms.CharField(required=False) + is_primary = forms.BooleanField(required=False) + + class Meta: + model = IPAddress + fields = ['address', 'vrf', 'device', 'interface_name', 'is_primary', 'description'] + + def clean(self): + + device = self.cleaned_data.get('device') + interface_name = self.cleaned_data.get('interface_name') + is_primary = self.cleaned_data.get('is_primary') + + # Validate interface + if device and interface_name: + try: + Interface.objects.get(device=device, name=interface_name) + except Interface.DoesNotExist: + self.add_error('interface_name', "Invalid interface ({}) for {}".format(interface_name, device)) + elif device and not interface_name: + self.add_error('interface_name', "Device set ({}) but interface missing".format(device)) + elif interface_name and not device: + self.add_error('device', "Interface set ({}) but device missing or invalid".format(interface_name)) + + # Validate is_primary + if is_primary and not device: + self.add_error('is_primary', "No device specified; cannot set as primary IP") + + def save(self, commit=True): + + # Set interface + if self.cleaned_data['device'] and self.cleaned_data['interface_name']: + self.instance.interface = Interface.objects.get(device=self.cleaned_data['device'], + name=self.cleaned_data['interface_name']) + # Set as primary for device + if self.cleaned_data['is_primary']: + self.instance.primary_for = self.cleaned_data['device'] + + return super(IPAddressFromCSVForm, self).save(commit=commit) + + +class IPAddressImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=IPAddressFromCSVForm) + + +class IPAddressBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF', + help_text="Select the VRF to assign, or check below to remove VRF assignment") + vrf_global = forms.BooleanField(required=False, label='Set VRF to global') + description = forms.CharField(max_length=50, required=False) + + +class IPAddressBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=IPAddress.objects.all(), widget=forms.MultipleHiddenInput) + + +def ipaddress_family_choices(): + return [('', 'All'), (4, 'IPv4'), (6, 'IPv6')] + + +class IPAddressFilterForm(forms.Form, BootstrapMixin): + family = forms.ChoiceField(required=False, choices=ipaddress_family_choices, label='Address Family') + + +# +# VLANs +# + +class VLANForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = VLAN + fields = ['site', 'vid', 'name', 'status', 'role'] + help_texts = { + 'site': "The site at which this VLAN exists", + 'vid': "Configured VLAN ID", + 'name': "Configured VLAN name", + 'status': "Operational status of this VLAN", + 'role': "The primary function of this VLAN", + } + + +class VLANFromCSVForm(forms.ModelForm): + site = forms.ModelChoiceField(queryset=Site.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Device not found.'}) + status = forms.ModelChoiceField(queryset=Status.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid status.'}) + role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False, to_field_name='name', + error_messages={'invalid_choice': 'Invalid role.'}) + + class Meta: + model = VLAN + fields = ['site', 'vid', 'name', 'status', 'role'] + + +class VLANImportForm(BulkImportForm, BootstrapMixin): + csv = CSVDataField(csv_form=VLANFromCSVForm) + + +class VLANBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) + status = forms.ModelChoiceField(queryset=Status.objects.all(), required=False) + role = forms.ModelChoiceField(queryset=Role.objects.all(), required=False) + + +class VLANBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=VLAN.objects.all(), widget=forms.MultipleHiddenInput) + + +def vlan_site_choices(): + site_choices = Site.objects.annotate(vlan_count=Count('vlans')) + return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in site_choices] + + +def vlan_status_choices(): + status_choices = Status.objects.annotate(vlan_count=Count('vlans')) + return [(s.slug, '{} ({})'.format(s.name, s.vlan_count)) for s in status_choices] + + +def vlan_role_choices(): + role_choices = Role.objects.annotate(vlan_count=Count('vlans')) + return [(r.slug, '{} ({})'.format(r.name, r.vlan_count)) for r in role_choices] + + +class VLANFilterForm(forms.Form, BootstrapMixin): + site = forms.MultipleChoiceField(required=False, choices=vlan_site_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) + status = forms.MultipleChoiceField(required=False, choices=vlan_status_choices) + role = forms.MultipleChoiceField(required=False, choices=vlan_role_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) diff --git a/netbox/ipam/lookups.py b/netbox/ipam/lookups.py new file mode 100644 index 000000000..b29890723 --- /dev/null +++ b/netbox/ipam/lookups.py @@ -0,0 +1,89 @@ +from django.db.models import Lookup +from django.db.models.lookups import BuiltinLookup + + +class NetFieldDecoratorMixin(object): + + def process_lhs(self, qn, connection, lhs=None): + lhs = lhs or self.lhs + lhs_string, lhs_params = qn.compile(lhs) + lhs_string = 'TEXT(%s)' % lhs_string + return lhs_string, lhs_params + + +class EndsWith(NetFieldDecoratorMixin, BuiltinLookup): + lookup_name = 'endswith' + + +class IEndsWith(NetFieldDecoratorMixin, BuiltinLookup): + lookup_name = 'iendswith' + + +class StartsWith(NetFieldDecoratorMixin, BuiltinLookup): + lookup_name = 'startswith' + + +class IStartsWith(NetFieldDecoratorMixin, BuiltinLookup): + lookup_name = 'istartswith' + + +class Regex(NetFieldDecoratorMixin, BuiltinLookup): + lookup_name = 'regex' + + +class IRegex(NetFieldDecoratorMixin, BuiltinLookup): + lookup_name = 'iregex' + + +class NetContainsOrEquals(Lookup): + lookup_name = 'net_contains_or_equals' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + params = lhs_params + rhs_params + return '%s >>= %s' % (lhs, rhs), params + + +class NetContains(Lookup): + lookup_name = 'net_contains' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + params = lhs_params + rhs_params + return '%s >> %s' % (lhs, rhs), params + + +class NetContained(Lookup): + lookup_name = 'net_contained' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + params = lhs_params + rhs_params + return '%s << %s' % (lhs, rhs), params + + +class NetContainedOrEqual(Lookup): + lookup_name = 'net_contained_or_equal' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + params = lhs_params + rhs_params + return '%s <<= %s' % (lhs, rhs), params + + +class NetHost(Lookup): + lookup_name = 'net_host' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + # Query parameters are automatically converted to IPNetwork objects, which are then turned to strings. We need + # to omit the mask portion of the object's string representation to match PostgreSQL's HOST() function. + if rhs_params: + rhs_params[0] = rhs_params[0].split('/')[0] + params = lhs_params + rhs_params + return 'HOST(%s) = %s' % (lhs, rhs), params diff --git a/netbox/ipam/migrations/0001_initial.py b/netbox/ipam/migrations/0001_initial.py new file mode 100644 index 000000000..d75b76f2d --- /dev/null +++ b/netbox/ipam/migrations/0001_initial.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-27 02:35 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('dcim', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='Aggregate', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')])), + ('prefix', ipam.fields.IPNetworkField()), + ('date_added', models.DateField(blank=True, null=True)), + ('description', models.CharField(blank=True, max_length=100)), + ], + options={ + 'ordering': ['family', 'prefix'], + }, + ), + migrations.CreateModel( + name='IPAddress', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')], editable=False)), + ('address', ipam.fields.IPAddressField()), + ('description', models.CharField(blank=True, max_length=100)), + ('interface', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ip_addresses', to='dcim.Interface')), + ('nat_inside', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='nat_outside', to='ipam.IPAddress', verbose_name=b'NAT IP (inside)')), + ], + options={ + 'ordering': ['family', 'address'], + 'verbose_name': 'IP address', + 'verbose_name_plural': 'IP addresses', + }, + ), + migrations.CreateModel( + name='Prefix', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('family', models.PositiveSmallIntegerField(choices=[(4, b'IPv4'), (6, b'IPv6')], editable=False)), + ('prefix', ipam.fields.IPNetworkField()), + ('description', models.CharField(blank=True, max_length=100)), + ], + options={ + 'ordering': ['family', 'prefix'], + 'verbose_name_plural': 'prefixes', + }, + ), + migrations.CreateModel( + name='RIR', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'RIR', + 'verbose_name_plural': 'RIRs', + }, + ), + migrations.CreateModel( + name='Role', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ], + options={ + 'ordering': ['weight', 'name'], + }, + ), + migrations.CreateModel( + name='Status', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('weight', models.PositiveSmallIntegerField(default=1000)), + ('bootstrap_class', models.PositiveSmallIntegerField(choices=[(0, b'Default'), (1, b'Primary'), (2, b'Success'), (3, b'Info'), (4, b'Warning'), (5, b'Danger')], default=0)), + ], + options={ + 'ordering': ['weight', 'name'], + 'verbose_name_plural': 'statuses', + }, + ), + migrations.CreateModel( + name='VLAN', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('vid', models.PositiveSmallIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(4094)], verbose_name=b'ID')), + ('name', models.CharField(max_length=30)), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vlans', to='ipam.Role')), + ('site', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='dcim.Site')), + ('status', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='vlans', to='ipam.Status')), + ], + options={ + 'ordering': ['site', 'vid'], + 'verbose_name': 'VLAN', + 'verbose_name_plural': 'VLANs', + }, + ), + migrations.CreateModel( + name='VRF', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50)), + ('rd', models.CharField(max_length=21, unique=True, verbose_name=b'Route distinguisher')), + ('description', models.CharField(blank=True, max_length=100)), + ], + options={ + 'ordering': ['name'], + 'verbose_name': 'VRF', + 'verbose_name_plural': 'VRFs', + }, + ), + migrations.AddField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AddField( + model_name='prefix', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='dcim.Site'), + ), + migrations.AddField( + model_name='prefix', + name='status', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.Status'), + ), + migrations.AddField( + model_name='prefix', + name='vlan', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VLAN', verbose_name=b'VLAN'), + ), + migrations.AddField( + model_name='prefix', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='prefixes', to='ipam.VRF', verbose_name=b'VRF'), + ), + migrations.AddField( + model_name='ipaddress', + name='vrf', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='ip_addresses', to='ipam.VRF', verbose_name=b'VRF'), + ), + migrations.AddField( + model_name='aggregate', + name='rir', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='aggregates', to='ipam.RIR', verbose_name=b'RIR'), + ), + ] diff --git a/netbox/ipam/migrations/__init__.py b/netbox/ipam/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py new file mode 100644 index 000000000..250ca7ac7 --- /dev/null +++ b/netbox/ipam/models.py @@ -0,0 +1,275 @@ +from netaddr import cidr_merge + +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse +from django.core.validators import MaxValueValidator, MinValueValidator +from django.db import models + +from dcim.models import Interface +from .fields import IPNetworkField, IPAddressField + + +AF_CHOICES = ( + (4, 'IPv4'), + (6, 'IPv6'), +) + +BOOTSTRAP_CLASS_CHOICES = ( + (0, 'Default'), + (1, 'Primary'), + (2, 'Success'), + (3, 'Info'), + (4, 'Warning'), + (5, 'Danger'), +) + + +class VRF(models.Model): + """ + A discrete layer three forwarding domain (e.g. a routing table) + """ + name = models.CharField(max_length=50) + rd = models.CharField(max_length=21, unique=True, verbose_name='Route distinguisher') + description = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['name'] + verbose_name = 'VRF' + verbose_name_plural = 'VRFs' + + def __unicode__(self): + return self.name + + def get_absolute_url(self): + return reverse('ipam:vrf', args=[self.pk]) + + +class Status(models.Model): + """ + The status of a prefix or VLAN (e.g. allocated, reserved, etc.) + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + weight = models.PositiveSmallIntegerField(default=1000) + bootstrap_class = models.PositiveSmallIntegerField(choices=BOOTSTRAP_CLASS_CHOICES, default=0) + + class Meta: + ordering = ['weight', 'name'] + verbose_name_plural = 'statuses' + + def __unicode__(self): + return self.name + + +class Role(models.Model): + """ + The role of an address resource (e.g. customer, infrastructure, mgmt, etc.) + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + weight = models.PositiveSmallIntegerField(default=1000) + + class Meta: + ordering = ['weight', 'name'] + + def __unicode__(self): + return self.name + + +class RIR(models.Model): + """ + A regional Internet registry (e.g. ARIN) or governing standard (e.g. RFC 1918) + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class Meta: + ordering = ['name'] + verbose_name = 'RIR' + verbose_name_plural = 'RIRs' + + def __unicode__(self): + return self.name + + +class Aggregate(models.Model): + """ + A top-level IPv4 or IPv6 prefix + """ + family = models.PositiveSmallIntegerField(choices=AF_CHOICES) + prefix = IPNetworkField() + rir = models.ForeignKey('RIR', related_name='aggregates', on_delete=models.PROTECT, verbose_name='RIR') + date_added = models.DateField(blank=True, null=True) + description = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['family', 'prefix'] + + def __unicode__(self): + return str(self.prefix) + + def get_absolute_url(self): + return reverse('ipam:aggregate', args=[self.pk]) + + def clean(self): + + if self.prefix: + + # Clear host bits from prefix + self.prefix = self.prefix.cidr + + # Ensure that the aggregate being added is not covered by an existing aggregate + covering_aggregates = Aggregate.objects.filter(prefix__net_contains_or_equals=str(self.prefix)) + if self.pk: + covering_aggregates = covering_aggregates.exclude(pk=self.pk) + if covering_aggregates: + raise ValidationError("{} is already covered by an existing aggregate ({})" + .format(self.prefix, covering_aggregates[0])) + + + def save(self, *args, **kwargs): + if self.prefix: + # Infer address family from IPNetwork object + self.family = self.prefix.version + super(Aggregate, self).save(*args, **kwargs) + + def get_utilization(self): + """ + Determine the utilization rate of the aggregate prefix and return it as a percentage. + """ + child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix)) + # Remove overlapping prefixes from list of children + networks = cidr_merge([c.prefix for c in child_prefixes]) + children_size = float(0) + for p in networks: + children_size += p.size + return int(children_size / self.prefix.size * 100) + + +class PrefixQuerySet(models.QuerySet): + + def annotate_depth(self, limit=None): + """ + Iterate through a QuerySet of Prefixes and annotate the hierarchical level of each. While it would be preferable + to do this using .extra() on the QuerySet to count the unique parents of each prefix, that approach introduces + performance issues at scale. + + Because we're adding a non-field attribute to the model, annotation must be made *after* any QuerySet + modifications. + """ + queryset = self + stack = [] + for p in queryset: + try: + prev_p = stack[-1] + except IndexError: + prev_p = None + if prev_p is not None: + while (p.prefix not in prev_p.prefix) or p.prefix == prev_p.prefix: + stack.pop() + try: + prev_p = stack[-1] + except IndexError: + prev_p = None + break + if prev_p is not None: + prev_p.has_children = True + stack.append(p) + p.depth = len(stack) - 1 + if limit is None: + return queryset + return filter(lambda p: p.depth <= limit, queryset) + + +class Prefix(models.Model): + """ + An IPv4 or IPv6 prefix, including mask length + """ + family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) + prefix = IPNetworkField() + site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True) + vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') + vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VLAN') + status = models.ForeignKey('Status', related_name='prefixes', on_delete=models.PROTECT) + role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True) + description = models.CharField(max_length=100, blank=True) + + objects = PrefixQuerySet.as_manager() + + class Meta: + ordering = ['family', 'prefix'] + verbose_name_plural = 'prefixes' + + def __unicode__(self): + return str(self.prefix) + + def get_absolute_url(self): + return reverse('ipam:prefix', args=[self.pk]) + + def save(self, *args, **kwargs): + if self.prefix: + # Clear host bits from prefix + self.prefix = self.prefix.cidr + # Infer address family from IPNetwork object + self.family = self.prefix.version + super(Prefix, self).save(*args, **kwargs) + + +class IPAddress(models.Model): + """ + An IPv4 or IPv6 address + """ + family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) + address = IPAddressField() + vrf = models.ForeignKey('VRF', related_name='ip_addresses', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') + interface = models.ForeignKey(Interface, related_name='ip_addresses', on_delete=models.CASCADE, blank=True, null=True) + nat_inside = models.OneToOneField('self', related_name='nat_outside', on_delete=models.SET_NULL, blank=True, null=True, verbose_name='NAT IP (inside)') + description = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['family', 'address'] + verbose_name = 'IP address' + verbose_name_plural = 'IP addresses' + + def __unicode__(self): + return str(self.address) + + def get_absolute_url(self): + return reverse('ipam:ipaddress', args=[self.pk]) + + def save(self, *args, **kwargs): + if self.address: + # Infer address family from IPAddress object + self.family = self.address.version + super(IPAddress, self).save(*args, **kwargs) + + @property + def device(self): + if self.interface: + return self.interface.device + return None + + +class VLAN(models.Model): + """ + A VLAN within a site + """ + site = models.ForeignKey('dcim.Site', related_name='vlans', on_delete=models.PROTECT) + vid = models.PositiveSmallIntegerField(verbose_name='ID', validators=[ + MinValueValidator(1), + MaxValueValidator(4094) + ]) + name = models.CharField(max_length=30) + status = models.ForeignKey('Status', related_name='vlans', on_delete=models.PROTECT) + role = models.ForeignKey('Role', related_name='vlans', on_delete=models.SET_NULL, blank=True, null=True) + + class Meta: + ordering = ['site', 'vid'] + verbose_name = 'VLAN' + verbose_name_plural = 'VLANs' + + def __unicode__(self): + return "{0} ({1})".format(self.vid, self.name) + + def get_absolute_url(self): + return reverse('ipam:vlan', args=[self.pk]) diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py new file mode 100644 index 000000000..a5e499f50 --- /dev/null +++ b/netbox/ipam/tables.py @@ -0,0 +1,210 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from .models import Aggregate, Prefix, IPAddress, VLAN, VRF + + +UTILIZATION_GRAPH = """ +{% with record.get_utilization as percentage %} +
+ {% if percentage < 15 %}{{ percentage }}%{% endif %} +
+ {% if percentage >= 15 %}{{ percentage }}%{% endif %} +
+
+{% endwith %} +""" + +PREFIX_LINK = """ +{% if record.has_children %} + +{% else %} + +{% endif %} + {{ record.prefix }} + +""" + +PREFIX_LINK_BRIEF = """ + + {{ record.prefix }} + +""" + +STATUS_LABEL = """ +{% if record.pk %} + {{ record.status.name }} +{% else %} + Available +{% endif %} +""" + + +# +# VRFs +# + +class VRFTable(tables.Table): + name = tables.LinkColumn('ipam:vrf', args=[Accessor('pk')], verbose_name='Name') + rd = tables.Column(verbose_name='RD') + description = tables.Column(sortable=False, verbose_name='Description') + + class Meta: + model = VRF + fields = ('name', 'rd', 'description') + empty_text = "No VRFs found." + attrs = { + 'class': 'table table-hover', + } + + +class VRFBulkEditTable(VRFTable): + pk = tables.CheckBoxColumn() + + class Meta(VRFTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'name', 'rd', 'description') + + +# +# Aggregates +# + +class AggregateTable(tables.Table): + prefix = tables.LinkColumn('ipam:aggregate', args=[Accessor('pk')], verbose_name='Aggregate') + rir = tables.Column(verbose_name='RIR') + child_count = tables.Column(verbose_name='Prefixes') + utilization = tables.TemplateColumn(UTILIZATION_GRAPH, orderable=False, verbose_name='Utilization') + date_added = tables.DateColumn(format="Y-m-d", verbose_name='Added') + description = tables.Column(sortable=False, verbose_name='Description') + + class Meta: + model = Aggregate + fields = ('prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description') + empty_text = "No aggregates found." + attrs = { + 'class': 'table table-hover', + } + + +class AggregateBulkEditTable(AggregateTable): + pk = tables.CheckBoxColumn() + + class Meta(AggregateTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'prefix', 'rir', 'child_count', 'utilization', 'date_added', 'description') + + +# +# Prefixes +# + +class PrefixTable(tables.Table): + status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') + prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix') + vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + role = tables.Column(verbose_name='Role') + description = tables.Column(sortable=False, verbose_name='Description') + + class Meta: + model = Prefix + fields = ('prefix', 'status', 'vrf', 'site', 'role', 'description') + empty_text = "No prefixes found." + attrs = { + 'class': 'table table-hover', + } + + +class PrefixBriefTable(tables.Table): + prefix = tables.TemplateColumn(PREFIX_LINK_BRIEF, verbose_name='Prefix') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') + role = tables.Column(verbose_name='Role') + + class Meta: + model = Prefix + fields = ('prefix', 'status', 'site', 'role') + empty_text = "No prefixes found." + attrs = { + 'class': 'table table-hover', + } + + +class PrefixBulkEditTable(PrefixTable): + pk = tables.CheckBoxColumn(default='') + + class Meta(PrefixTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'prefix', 'status', 'vrf', 'site', 'role', 'description') + + +# +# IPAddresses +# + +class IPAddressTable(tables.Table): + address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') + vrf = tables.Column(orderable=False, default='Global', verbose_name='VRF') + device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device') + interface = tables.Column(orderable=False, verbose_name='Interface') + description = tables.Column(sortable=False, verbose_name='Description') + + class Meta: + model = IPAddress + fields = ('address', 'vrf', 'device', 'interface', 'description') + empty_text = "No IP addresses found." + attrs = { + 'class': 'table table-hover', + } + + +class IPAddressBriefTable(tables.Table): + address = tables.LinkColumn('ipam:ipaddress', args=[Accessor('pk')], verbose_name='IP Address') + device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device') + interface = tables.Column(orderable=False, verbose_name='Interface') + nat_inside = tables.LinkColumn('ipam:ipaddress', args=[Accessor('nat_inside.pk')], orderable=False, verbose_name='NAT (Inside)') + + class Meta: + model = IPAddress + fields = ('address', 'device', 'interface', 'nat_inside') + empty_text = "No IP addresses found." + attrs = { + 'class': 'table table-hover', + } + + +class IPAddressBulkEditTable(IPAddressTable): + pk = tables.CheckBoxColumn() + + class Meta(IPAddressTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'address', 'vrf', 'device', 'interface', 'description') + + +# +# VLANs +# + +class VLANTable(tables.Table): + vid = tables.LinkColumn('ipam:vlan', args=[Accessor('pk')], verbose_name='ID') + site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') + name = tables.Column(verbose_name='Name') + status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') + role = tables.Column(verbose_name='Role') + + class Meta: + model = VLAN + fields = ('vid', 'site', 'name', 'status', 'role') + empty_text = "No VLANs found." + attrs = { + 'class': 'table table-hover', + } + + +class VLANBulkEditTable(VLANTable): + pk = tables.CheckBoxColumn() + + class Meta(VLANTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'vid', 'site', 'name', 'status', 'role') diff --git a/netbox/ipam/tests.py b/netbox/ipam/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/netbox/ipam/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py new file mode 100644 index 000000000..dd5a83fee --- /dev/null +++ b/netbox/ipam/urls.py @@ -0,0 +1,51 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^vrfs/$', views.vrf_list, name='vrf_list'), + url(r'^vrfs/add/$', views.vrf_add, name='vrf_add'), + url(r'^vrfs/import/$', views.VRFBulkImportView.as_view(), name='vrf_import'), + url(r'^vrfs/edit/$', views.VRFBulkEditView.as_view(), name='vrf_bulk_edit'), + url(r'^vrfs/delete/$', views.VRFBulkDeleteView.as_view(), name='vrf_bulk_delete'), + url(r'^vrfs/(?P\d+)/$', views.vrf, name='vrf'), + url(r'^vrfs/(?P\d+)/edit/$', views.vrf_edit, name='vrf_edit'), + url(r'^vrfs/(?P\d+)/delete/$', views.vrf_delete, name='vrf_delete'), + + url(r'^aggregates/$', views.aggregate_list, name='aggregate_list'), + url(r'^aggregates/add/$', views.aggregate_add, name='aggregate_add'), + url(r'^aggregates/import/$', views.AggregateBulkImportView.as_view(), name='aggregate_import'), + url(r'^aggregates/edit/$', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'), + url(r'^aggregates/delete/$', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'), + url(r'^aggregates/(?P\d+)/$', views.aggregate, name='aggregate'), + url(r'^aggregates/(?P\d+)/edit/$', views.aggregate_edit, name='aggregate_edit'), + url(r'^aggregates/(?P\d+)/delete/$', views.aggregate_delete, name='aggregate_delete'), + + url(r'^prefixes/$', views.prefix_list, name='prefix_list'), + url(r'^prefixes/add/$', views.prefix_add, name='prefix_add'), + url(r'^prefixes/import/$', views.PrefixBulkImportView.as_view(), name='prefix_import'), + url(r'^prefixes/edit/$', views.PrefixBulkEditView.as_view(), name='prefix_bulk_edit'), + url(r'^prefixes/delete/$', views.PrefixBulkDeleteView.as_view(), name='prefix_bulk_delete'), + url(r'^prefixes/(?P\d+)/$', views.prefix, name='prefix'), + url(r'^prefixes/(?P\d+)/edit/$', views.prefix_edit, name='prefix_edit'), + url(r'^prefixes/(?P\d+)/delete/$', views.prefix_delete, name='prefix_delete'), + url(r'^prefixes/(?P\d+)/ip-addresses/$', views.prefix_ipaddresses, name='prefix_ipaddresses'), + + url(r'^ip-addresses/$', views.ipaddress_list, name='ipaddress_list'), + url(r'^ip-addresses/add/$', views.ipaddress_add, name='ipaddress_add'), + url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), + url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), + url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), + url(r'^ip-addresses/(?P\d+)/$', views.ipaddress, name='ipaddress'), + url(r'^ip-addresses/(?P\d+)/edit/$', views.ipaddress_edit, name='ipaddress_edit'), + url(r'^ip-addresses/(?P\d+)/delete/$', views.ipaddress_delete, name='ipaddress_delete'), + + url(r'^vlans/$', views.vlan_list, name='vlan_list'), + url(r'^vlans/add/$', views.vlan_add, name='vlan_add'), + url(r'^vlans/import/$', views.VLANBulkImportView.as_view(), name='vlan_import'), + url(r'^vlans/edit/$', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), + url(r'^vlans/delete/$', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), + url(r'^vlans/(?P\d+)/$', views.vlan, name='vlan'), + url(r'^vlans/(?P\d+)/edit/$', views.vlan_edit, name='vlan_edit'), + url(r'^vlans/(?P\d+)/delete/$', views.vlan_delete, name='vlan_delete'), +] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py new file mode 100644 index 000000000..205703c39 --- /dev/null +++ b/netbox/ipam/views.py @@ -0,0 +1,899 @@ +from netaddr import IPNetwork, IPSet +from netaddr.core import AddrFormatError + +from django_tables2 import RequestConfig +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import permission_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.urlresolvers import reverse +from django.db.models import ProtectedError +from django.http import HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.http import urlencode + +from dcim.models import Device +from extras.models import ExportTemplate +from utilities.error_handlers import handle_protectederror +from utilities.forms import ConfirmationForm +from utilities.paginator import EnhancedPaginator +from utilities.views import BulkImportView, BulkEditView, BulkDeleteView + +from .filters import AggregateFilter, PrefixFilter, IPAddressFilter, VLANFilter, VRFFilter +from .forms import AggregateForm, AggregateImportForm, AggregateBulkEditForm, AggregateBulkDeleteForm, \ + AggregateFilterForm, PrefixForm, PrefixImportForm, PrefixBulkEditForm, PrefixBulkDeleteForm, PrefixFilterForm, \ + IPAddressForm, IPAddressImportForm, IPAddressBulkEditForm, IPAddressBulkDeleteForm, IPAddressFilterForm, VLANForm, \ + VLANImportForm, VLANBulkEditForm, VLANBulkDeleteForm, VRFForm, VRFImportForm, VRFBulkEditForm, VRFBulkDeleteForm, \ + VLANFilterForm +from .models import VRF, Aggregate, Prefix, VLAN +from .tables import AggregateTable, AggregateBulkEditTable, PrefixTable, PrefixBriefTable, PrefixBulkEditTable, \ + IPAddress, IPAddressBriefTable, IPAddressTable, IPAddressBulkEditTable, VLANTable, VLANBulkEditTable, VRFTable, \ + VRFBulkEditTable + + +def add_available_prefixes(parent, prefix_list): + """ + Create fake Prefix objects for all unallocated space within a prefix. + """ + + # Find all unallocated space + available_prefixes = IPSet(parent) ^ IPSet([p.prefix for p in prefix_list]) + available_prefixes = [Prefix(prefix=p) for p in available_prefixes.iter_cidrs()] + + # Concatenate and sort complete list of children + prefix_list = list(prefix_list) + available_prefixes + prefix_list.sort(key=lambda p: p.prefix) + + return prefix_list + + +# +# VRFs +# + +def vrf_list(request): + + queryset = VRF.objects.all() + queryset = VRFFilter(request.GET, queryset).qs + # annotate_depth(queryset) + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='vrf', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_vrfs') + return response + + if request.user.has_perm('ipam.change_vrf') or request.user.has_perm('ipam.delete_vrf'): + vrf_table = VRFBulkEditTable(queryset) + else: + vrf_table = VRFTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(vrf_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='vrf') + + return render(request, 'ipam/vrf_list.html', { + 'vrf_table': vrf_table, + 'export_templates': export_templates, + }) + + +def vrf(request, pk): + + vrf = get_object_or_404(VRF.objects.all(), pk=pk) + prefixes = Prefix.objects.filter(vrf=vrf) + + return render(request, 'ipam/vrf.html', { + 'vrf': vrf, + 'prefixes': prefixes, + }) + + +@permission_required('ipam.add_vrf') +def vrf_add(request): + + if request.method == 'POST': + form = VRFForm(request.POST) + if form.is_valid(): + vrf = form.save() + messages.success(request, "Added new VRF: {0}".format(vrf)) + if '_addanother' in request.POST: + return redirect('ipam:vrf_add') + else: + return redirect('ipam:vrf', pk=vrf.pk) + + else: + form = VRFForm() + + return render(request, 'ipam/vrf_edit.html', { + 'form': form, + 'cancel_url': reverse('ipam:vrf_list'), + }) + + +@permission_required('ipam.change_vrf') +def vrf_edit(request, pk): + + vrf = get_object_or_404(VRF, pk=pk) + + if request.method == 'POST': + form = VRFForm(request.POST, instance=vrf) + if form.is_valid(): + vrf = form.save() + messages.success(request, "Modified VRF {0}".format(vrf)) + return redirect('ipam:vrf', pk=vrf.pk) + + else: + form = VRFForm(instance=vrf) + + return render(request, 'ipam/vrf_edit.html', { + 'vrf': vrf, + 'form': form, + 'cancel_url': reverse('ipam:vrf', kwargs={'pk': vrf.pk}), + }) + + +@permission_required('ipam.delete_vrf') +def vrf_delete(request, pk): + + vrf = get_object_or_404(VRF, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + vrf.delete() + messages.success(request, "VRF {0} has been deleted".format(vrf)) + return redirect('ipam:vrf_list') + except ProtectedError, e: + handle_protectederror(vrf, request, e) + return redirect('ipam:vrf', pk=vrf.pk) + + else: + form = ConfirmationForm() + + return render(request, 'ipam/vrf_delete.html', { + 'vrf': vrf, + 'form': form, + 'cancel_url': reverse('ipam:vrf', kwargs={'pk': vrf.pk}) + }) + + +class VRFBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_vrf' + form = VRFImportForm + table = VRFTable + template_name = 'ipam/vrf_import.html' + obj_list_url = 'ipam:vrf_list' + + +class VRFBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_vrf' + cls = VRF + form = VRFBulkEditForm + template_name = 'ipam/vrf_bulk_edit.html' + redirect_url = 'ipam:vrf_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['description']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} VRFs".format(updated_count)) + + +class VRFBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_vrf' + cls = VRF + form = VRFBulkDeleteForm + template_name = 'ipam/vrf_bulk_delete.html' + redirect_url = 'ipam:vrf_list' + + +# +# Aggregates +# + +def aggregate_list(request): + + queryset = Aggregate.objects.select_related('rir').extra( + select = { + 'child_count': 'SELECT COUNT(*) FROM ipam_prefix WHERE ipam_prefix.prefix <<= ipam_aggregate.prefix', + } + ) + queryset = AggregateFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='aggregate', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_aggregates') + return response + + if request.user.has_perm('ipam.change_aggregate') or request.user.has_perm('ipam.delete_aggregate'): + aggregate_table = AggregateBulkEditTable(queryset) + else: + aggregate_table = AggregateTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\ + .configure(aggregate_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='aggregate') + + return render(request, 'ipam/aggregate_list.html', { + 'aggregate_table': aggregate_table, + 'export_templates': export_templates, + 'filter_form': AggregateFilterForm(request.GET, label_suffix=''), + }) + + +def aggregate(request, pk): + + aggregate = get_object_or_404(Aggregate, pk=pk) + + # Find all child prefixes contained by this aggregate + child_prefixes = Prefix.objects.filter(prefix__net_contained_or_equal=str(aggregate.prefix))\ + .select_related('site', 'status', 'role').annotate_depth(limit=0) + child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes) + + if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): + prefix_table = PrefixBulkEditTable(child_prefixes) + else: + prefix_table = PrefixTable(child_prefixes) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\ + .configure(prefix_table) + + return render(request, 'ipam/aggregate.html', { + 'aggregate': aggregate, + 'prefix_table': prefix_table, + }) + + +@permission_required('ipam.add_aggregate') +def aggregate_add(request): + + if request.method == 'POST': + form = AggregateForm(request.POST) + if form.is_valid(): + aggregate = form.save() + messages.success(request, "Added new aggregate: {0}".format(aggregate.prefix)) + if '_addanother' in request.POST: + return redirect('ipam:aggregate_add') + else: + return redirect('ipam:aggregate', pk=aggregate.pk) + + else: + form = AggregateForm() + + return render(request, 'ipam/aggregate_edit.html', { + 'form': form, + 'cancel_url': reverse('ipam:aggregate_list'), + }) + + +@permission_required('ipam.change_aggregate') +def aggregate_edit(request, pk): + + aggregate = get_object_or_404(Aggregate, pk=pk) + + if request.method == 'POST': + form = AggregateForm(request.POST, instance=aggregate) + if form.is_valid(): + aggregate = form.save() + messages.success(request, "Modified aggregate {0}".format(aggregate.prefix)) + return redirect('ipam:aggregate', pk=aggregate.pk) + + else: + form = AggregateForm(instance=aggregate) + + return render(request, 'ipam/aggregate_edit.html', { + 'aggregate': aggregate, + 'form': form, + 'cancel_url': reverse('ipam:aggregate', kwargs={'pk': aggregate.pk}), + }) + + +@permission_required('ipam.delete_aggregate') +def aggregate_delete(request, pk): + + aggregate = get_object_or_404(Aggregate, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + aggregate.delete() + messages.success(request, "Aggregate {0} has been deleted".format(aggregate)) + return redirect('ipam:aggregate_list') + except ProtectedError, e: + handle_protectederror(aggregate, request, e) + return redirect('ipam:aggregate', pk=aggregate.pk) + + else: + form = ConfirmationForm() + + return render(request, 'ipam/aggregate_delete.html', { + 'aggregate': aggregate, + 'form': form, + 'cancel_url': reverse('ipam:aggregate', kwargs={'pk': aggregate.pk}) + }) + + +class AggregateBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_aggregate' + form = AggregateImportForm + table = AggregateTable + template_name = 'ipam/aggregate_import.html' + obj_list_url = 'ipam:aggregate_list' + + +class AggregateBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_aggregate' + cls = Aggregate + form = AggregateBulkEditForm + template_name = 'ipam/aggregate_bulk_edit.html' + redirect_url = 'ipam:aggregate_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['rir', 'date_added', 'description']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} aggregates".format(updated_count)) + + +class AggregateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_aggregate' + cls = Aggregate + form = AggregateBulkDeleteForm + template_name = 'ipam/aggregate_bulk_delete.html' + redirect_url = 'ipam:aggregate_list' + + +# +# Prefixes +# + +def prefix_list(request): + + queryset = Prefix.objects.select_related('site', 'status', 'role') + queryset = PrefixFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='prefix', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_prefixes') + return response + + # Show only top-level prefixes by default + limit = None if request.GET.get('expand') else 0 + prefixes = queryset.annotate_depth(limit=limit) + + if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): + prefix_table = PrefixBulkEditTable(prefixes) + else: + prefix_table = PrefixTable(prefixes) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(prefix_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='prefix') + + return render(request, 'ipam/prefix_list.html', { + 'prefix_table': prefix_table, + 'export_templates': export_templates, + 'filter_form': PrefixFilterForm(request.GET, label_suffix=''), + }) + + +def prefix(request, pk): + + prefix = get_object_or_404(Prefix.objects.select_related('site', 'vlan', 'status', 'role'), pk=pk) + + try: + aggregate = Aggregate.objects.get(prefix__net_contains_or_equals=str(prefix.prefix)) + except Aggregate.DoesNotExist: + aggregate = None + + # Count child IP addresses + ipaddress_count = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix)).count() + + # Parent prefixes table + parent_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contains=str(prefix.prefix))\ + .select_related('site', 'status', 'role').annotate_depth() + parent_prefix_table = PrefixBriefTable(parent_prefixes) + + # Duplicate prefixes table + duplicate_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix=str(prefix.prefix)).exclude(pk=prefix.pk)\ + .select_related('site', 'status', 'role') + duplicate_prefix_table = PrefixBriefTable(duplicate_prefixes) + + # Child prefixes table + child_prefixes = Prefix.objects.filter(vrf=prefix.vrf, prefix__net_contained=str(prefix.prefix))\ + .select_related('site', 'status', 'role').annotate_depth(limit=0) + if child_prefixes: + child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) + if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): + child_prefix_table = PrefixBulkEditTable(child_prefixes) + else: + child_prefix_table = PrefixTable(child_prefixes) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\ + .configure(child_prefix_table) + + return render(request, 'ipam/prefix.html', { + 'prefix': prefix, + 'aggregate': aggregate, + 'ipaddress_count': ipaddress_count, + 'parent_prefix_table': parent_prefix_table, + 'child_prefix_table': child_prefix_table, + 'duplicate_prefix_table': duplicate_prefix_table, + }) + + +@permission_required('ipam.add_prefix') +def prefix_add(request): + + if request.method == 'POST': + form = PrefixForm(request.POST) + if form.is_valid(): + prefix = form.save() + messages.success(request, "Added new prefix: {0}".format(prefix.prefix)) + if '_addanother' in request.POST: + return redirect('ipam:prefix_add') + else: + return redirect('ipam:prefix', pk=prefix.pk) + + else: + form = PrefixForm(initial={ + 'site': request.GET.get('site'), + 'prefix': request.GET.get('prefix'), + }) + + return render(request, 'ipam/prefix_edit.html', { + 'form': form, + 'cancel_url': reverse('ipam:prefix_list'), + }) + + +@permission_required('ipam.change_prefix') +def prefix_edit(request, pk): + + prefix = get_object_or_404(Prefix, pk=pk) + + if request.method == 'POST': + form = PrefixForm(request.POST, instance=prefix) + if form.is_valid(): + prefix = form.save() + messages.success(request, "Modified prefix {0}".format(prefix.prefix)) + return redirect('ipam:prefix', pk=prefix.pk) + + else: + form = PrefixForm(instance=prefix) + + return render(request, 'ipam/prefix_edit.html', { + 'prefix': prefix, + 'form': form, + 'cancel_url': reverse('ipam:prefix', kwargs={'pk': prefix.pk}), + }) + + +@permission_required('ipam.delete_prefix') +def prefix_delete(request, pk): + + prefix = get_object_or_404(Prefix, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + prefix.delete() + messages.success(request, "Prefix {0} has been deleted".format(prefix)) + return redirect('ipam:prefix_list') + except ProtectedError, e: + handle_protectederror(prefix, request, e) + return redirect('ipam:prefix', pk=prefix.pk) + + else: + form = ConfirmationForm() + + return render(request, 'ipam/prefix_delete.html', { + 'prefix': prefix, + 'form': form, + 'cancel_url': reverse('ipam:prefix', kwargs={'pk': prefix.pk}) + }) + + +class PrefixBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_prefix' + form = PrefixImportForm + table = PrefixTable + template_name = 'ipam/prefix_import.html' + obj_list_url = 'ipam:prefix_list' + + +class PrefixBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_prefix' + cls = Prefix + form = PrefixBulkEditForm + template_name = 'ipam/prefix_bulk_edit.html' + redirect_url = 'ipam:prefix_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + if form.cleaned_data['vrf']: + fields_to_update['vrf'] = form.cleaned_data['vrf'] + elif form.cleaned_data['vrf_global']: + fields_to_update['vrf'] = None + for field in ['site', 'status', 'role', 'description']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} prefixes".format(updated_count)) + + +class PrefixBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_prefix' + cls = Prefix + form = PrefixBulkDeleteForm + template_name = 'ipam/prefix_bulk_delete.html' + redirect_url = 'ipam:prefix_list' + + +def prefix_ipaddresses(request, pk): + + prefix = get_object_or_404(Prefix.objects.all(), pk=pk) + + # Find all IPAddresses belonging to this Prefix + ipaddresses = IPAddress.objects.filter(address__net_contained_or_equal=str(prefix.prefix))\ + .select_related('vrf', 'interface__device', 'primary_for') + + if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): + ip_table = IPAddressBulkEditTable(ipaddresses) + else: + ip_table = IPAddressTable(ipaddresses) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\ + .configure(ip_table) + + return render(request, 'ipam/prefix_ipaddresses.html', { + 'prefix': prefix, + 'ip_table': ip_table, + }) + + +# +# IP addresses +# + +def ipaddress_list(request): + + queryset = IPAddress.objects.select_related('vrf', 'interface__device', 'primary_for') + queryset = IPAddressFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='ipaddress', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_ips') + return response + + if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): + ip_table = IPAddressBulkEditTable(queryset) + else: + ip_table = IPAddressTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(ip_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='ipaddress') + + # If searching and no IPAddresses were found, include a list of parent prefixes matching the query + prefix_table = None + if request.GET.get('q') and not queryset: + try: + ip = str(IPNetwork(request.GET.get('q'))) + prefix_table = PrefixTable(Prefix.objects.filter(prefix__net_contains_or_equals=ip)) + RequestConfig(request).configure(prefix_table) + except AddrFormatError: + pass + + return render(request, 'ipam/ipaddress_list.html', { + 'ip_table': ip_table, + 'prefix_table': prefix_table, + 'export_templates': export_templates, + 'filter_form': IPAddressFilterForm(request.GET, label_suffix=''), + }) + + +def ipaddress(request, pk): + + ipaddress = get_object_or_404(IPAddress.objects.select_related('interface__device'), pk=pk) + + parent_prefixes = Prefix.objects.filter(vrf=ipaddress.vrf, prefix__net_contains=str(ipaddress.address.ip)) + related_ips = IPAddress.objects.select_related('interface__device').exclude(pk=ipaddress.pk).filter(vrf=ipaddress.vrf, address__net_contained_or_equal=str(ipaddress.address)) + + related_ips_table = IPAddressBriefTable(related_ips) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(related_ips_table) + + return render(request, 'ipam/ipaddress.html', { + 'ipaddress': ipaddress, + 'parent_prefixes': parent_prefixes, + 'related_ips_table': related_ips_table, + }) + + +@permission_required('ipam.add_ipaddress') +def ipaddress_add(request): + + if request.method == 'POST': + form = IPAddressForm(request.POST) + if form.is_valid(): + ipaddress = form.save() + messages.success(request, "Created new IP Address: {0}".format(ipaddress)) + if '_addanother' in request.POST: + return redirect('ipam:ipaddress_add') + else: + return redirect('ipam:ipaddress', pk=ipaddress.pk) + + else: + form = IPAddressForm(initial={ + 'ipaddress': request.GET.get('ipaddress', None), + }) + + return render(request, 'ipam/ipaddress_edit.html', { + 'form': form, + 'cancel_url': reverse('ipam:ipaddress_list'), + }) + + +@permission_required('ipam.change_ipaddress') +def ipaddress_edit(request, pk): + + ipaddress = get_object_or_404(IPAddress, pk=pk) + + if request.method == 'POST': + form = IPAddressForm(request.POST, instance=ipaddress) + if form.is_valid(): + ipaddress = form.save() + messages.success(request, "Modified IP address {0}".format(ipaddress)) + return redirect('ipam:ipaddress', pk=ipaddress.pk) + + else: + form = IPAddressForm(instance=ipaddress) + + return render(request, 'ipam/ipaddress_edit.html', { + 'ipaddress': ipaddress, + 'form': form, + 'cancel_url': reverse('ipam:ipaddress', kwargs={'pk': ipaddress.pk}), + }) + + +@permission_required('ipam.delete_ipaddress') +def ipaddress_delete(request, pk): + + ipaddress = get_object_or_404(IPAddress, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + ipaddress.delete() + messages.success(request, "IP address {0} has been deleted".format(ipaddress)) + if ipaddress.interface: + return redirect('dcim:device', pk=ipaddress.interface.device.pk) + else: + return redirect('ipam:ipaddress_list') + except ProtectedError, e: + handle_protectederror(ipaddress, request, e) + return redirect('ipam:ipaddress', pk=ipaddress.pk) + + else: + form = ConfirmationForm() + + # Upon cancellation, redirect to the assigned device if one exists + if ipaddress.interface: + cancel_url = reverse('dcim:device', kwargs={'pk': ipaddress.interface.device.pk}) + else: + cancel_url = reverse('ipam:ipaddress_list') + + return render(request, 'ipam/ipaddress_delete.html', { + 'ipaddress': ipaddress, + 'form': form, + 'cancel_url': cancel_url, + }) + + +class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_ipaddress' + form = IPAddressImportForm + table = IPAddressTable + template_name = 'ipam/ipaddress_import.html' + obj_list_url = 'ipam:ipaddress_list' + + def save_obj(self, obj): + obj.save() + # Update primary IP for device if needed + try: + device = obj.primary_for + device.primary_ip = obj + device.save() + except Device.DoesNotExist: + pass + + +class IPAddressBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_ipaddress' + cls = IPAddress + form = IPAddressBulkEditForm + template_name = 'ipam/ipaddress_bulk_edit.html' + redirect_url = 'ipam:ipaddress_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + if form.cleaned_data['vrf']: + fields_to_update['vrf'] = form.cleaned_data['vrf'] + elif form.cleaned_data['vrf_global']: + fields_to_update['vrf'] = None + for field in ['description']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} IP addresses".format(updated_count)) + + +class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_ipaddress' + cls = IPAddress + form = IPAddressBulkDeleteForm + template_name = 'ipam/ipaddress_bulk_delete.html' + redirect_url = 'ipam:ipaddress_list' + + +# +# VLANs +# + +def vlan_list(request): + + queryset = VLAN.objects.select_related('site', 'status', 'role') + queryset = VLANFilter(request.GET, queryset).qs + + # Export + if 'export' in request.GET: + et = get_object_or_404(ExportTemplate, content_type__model='vlan', name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': queryset}, filename='netbox_vlans') + return response + + if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'): + vlan_table = VLANBulkEditTable(queryset) + else: + vlan_table = VLANTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator}).configure(vlan_table) + + export_templates = ExportTemplate.objects.filter(content_type__model='vlan') + + return render(request, 'ipam/vlan_list.html', { + 'vlan_table': vlan_table, + 'export_templates': export_templates, + 'filter_form': VLANFilterForm(request.GET, label_suffix=''), + }) + + +def vlan(request, pk): + + vlan = get_object_or_404(VLAN.objects.select_related('site', 'status', 'role'), pk=pk) + prefixes = Prefix.objects.filter(vlan=vlan) + + return render(request, 'ipam/vlan.html', { + 'vlan': vlan, + 'prefixes': prefixes, + }) + + +@permission_required('ipam.add_vlan') +def vlan_add(request): + + if request.method == 'POST': + form = VLANForm(request.POST) + if form.is_valid(): + vlan = form.save() + messages.success(request, "Added new VLAN: {0}".format(vlan)) + if '_addanother' in request.POST: + base_url = reverse('ipam:vlan_add') + params = urlencode({ + 'site': vlan.site.pk, + }) + return HttpResponseRedirect('{}?{}'.format(base_url, params)) + else: + return redirect('ipam:vlan', pk=vlan.pk) + + else: + form = VLANForm() + + return render(request, 'ipam/vlan_edit.html', { + 'form': form, + 'cancel_url': reverse('ipam:vlan_list'), + }) + + +@permission_required('ipam.change_vlan') +def vlan_edit(request, pk): + + vlan = get_object_or_404(VLAN, pk=pk) + + if request.method == 'POST': + form = VLANForm(request.POST, instance=vlan) + if form.is_valid(): + vlan = form.save() + messages.success(request, "Modified VLAN {0}".format(vlan)) + return redirect('ipam:vlan', pk=vlan.pk) + + else: + form = VLANForm(instance=vlan) + + return render(request, 'ipam/vlan_edit.html', { + 'vlan': vlan, + 'form': form, + 'cancel_url': reverse('ipam:vlan', kwargs={'pk': vlan.pk}), + }) + + +@permission_required('ipam.delete_vlan') +def vlan_delete(request, pk): + + vlan = get_object_or_404(VLAN, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + vlan.delete() + messages.success(request, "VLAN {0} has been deleted".format(vlan)) + return redirect('ipam:vlan_list') + except ProtectedError, e: + handle_protectederror(vlan, request, e) + return redirect('ipam:vlan', pk=vlan.pk) + + else: + form = ConfirmationForm() + + return render(request, 'ipam/vlan_delete.html', { + 'vlan': vlan, + 'form': form, + 'cancel_url': reverse('ipam:vlan', kwargs={'pk': vlan.pk}) + }) + + +class VLANBulkImportView(PermissionRequiredMixin, BulkImportView): + permission_required = 'ipam.add_vlan' + form = VLANImportForm + table = VLANTable + template_name = 'ipam/vlan_import.html' + obj_list_url = 'ipam:vlan_list' + + +class VLANBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_vlan' + cls = VLAN + form = VLANBulkEditForm + template_name = 'ipam/vlan_bulk_edit.html' + redirect_url = 'ipam:vlan_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['site', 'status', 'role']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} VLANs".format(updated_count)) + + +class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_vlan' + cls = VLAN + form = VLANBulkDeleteForm + template_name = 'ipam/vlan_bulk_delete.html' + redirect_url = 'ipam:vlan_list' diff --git a/netbox/manage.py b/netbox/manage.py new file mode 100755 index 000000000..2ce3867f3 --- /dev/null +++ b/netbox/manage.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python +import os +import sys + +if __name__ == "__main__": + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) diff --git a/netbox/netbox/__init__.py b/netbox/netbox/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py new file mode 100644 index 000000000..cd75182d4 --- /dev/null +++ b/netbox/netbox/configuration.example.py @@ -0,0 +1,36 @@ +# This key is used for secure generation of random numbers and strings. It must never be exposed outside of this file. +# For optimal security, SECRET_KEY should be at least 50 characters in length and contain a mix of letters, numbers, and +# symbols. NetBox will not run without this defined. For more information, see +# https://docs.djangoproject.com/en/dev/ref/settings/#std:setting-SECRET_KEY +SECRET_KEY = '' + +# If enabled, NetBox will run with debugging turned on. This should only be used for development or troubleshooting. +# NEVER ENABLE DEBUGGING ON A PRODUCTION SYSTEM. +DEBUG = False + +# Set this to your server's FQDN. This is required when DEBUG = False. +# E.g. ALLOWED_HOSTS = ['netbox.yourdomain.com'] +ALLOWED_HOSTS = [] + +# Setting this to true will display a "maintenance mode" banner at the top of every page. +MAINTENANCE_MODE = False + +# PostgreSQL database configuration. +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': 'netbox', # Database name + 'USER': 'netbox', # PostgreSQL username + 'PASSWORD': '', # PostgreSQL password + 'HOST': 'localhost', # Database server + 'PORT': '', # Database port (leave blank for default) + } +} + +# If true, user authentication will be required for all site access. If false, unauthenticated users will be able to +# access NetBox but not make any changes. +LOGIN_REQUIRED = False + +# Credentials that NetBox will use to access live devices. (Optional) +NETBOX_USERNAME = '' +NETBOX_PASSWORD = '' diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py new file mode 100644 index 000000000..1127cde18 --- /dev/null +++ b/netbox/netbox/settings.py @@ -0,0 +1,140 @@ +""" +Django settings for netbox project. + +Generated by 'django-admin startproject' using Django 1.8.2. + +For more information on this file, see +https://docs.djangoproject.com/en/1.8/topics/settings/ + +For the full list of settings and their values, see +https://docs.djangoproject.com/en/1.8/ref/settings/ +""" + +# Build paths inside the project like this: os.path.join(BASE_DIR, ...) +import os +import socket + +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +# Application definition +INSTALLED_APPS = ( + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'corsheaders', + 'debug_toolbar', + 'django_extensions', + 'django_tables2', + 'rest_framework', + 'rest_framework_swagger', + 'circuits', + 'dcim', + 'ipam', + 'extras', + 'secrets', + 'users', + 'utilities', +) + +MIDDLEWARE_CLASSES = ( + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.security.SecurityMiddleware', + 'utilities.middleware.LoginRequiredMiddleware', +) + +ROOT_URLCONF = 'netbox.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR + '/templates/'], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'utilities.context_processors.settings', + 'django.core.context_processors.request', + ], + }, + }, +] + +WSGI_APPLICATION = 'netbox.wsgi.application' +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') + +# Internationalization +# https://docs.djangoproject.com/en/1.8/topics/i18n/ +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + + +# Static files (CSS, JavaScript, Images) +# https://docs.djangoproject.com/en/1.8/howto/static-files/ +STATIC_URL = '/static/' +STATICFILES_DIRS = ( + os.path.join(BASE_DIR, "project-static"), +) + +# Messages +from django.contrib.messages import constants as messages +MESSAGE_TAGS = { + messages.ERROR: 'danger', +} + +# Pagination +PAGINATE_COUNT = 50 + +# Authentication +LOGIN_URL = '/login/' +LOGIN_REDIRECT_URL = '/' +LOGOUT_URL = '/logout/' + +# Default time formats +DATE_FORMAT = 'N j, Y' +SHORT_DATE_FORMAT = 'Y-m-d' +TIME_FORMAT = 'g:i:s a' +SHORT_TIME_FORMAT = 'H:i:s' +DATETIME_FORMAT = 'N j, Y \a\t g:i a' +SHORT_DATETIME_FORMAT = 'Y-m-d H:i' + +# Secrets +SECRETS_MIN_PUBKEY_SIZE = 2048 + +# Django REST framework +REST_FRAMEWORK = { + 'DEFAULT_FILTER_BACKENDS': ('rest_framework.filters.DjangoFilterBackend',) +} + +try: + HOSTNAME = socket.gethostname() +except: + HOSTNAME = 'localhost' + +# Import local configuration +try: + from configuration import * +except ImportError: + pass + +# django-cors-headers (API Cross-Origin Resource Sharing) +if DEBUG: + CORS_ORIGIN_ALLOW_ALL = True +CORS_ALLOW_METHODS = ( + 'GET', + 'OPTIONS', +) diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py new file mode 100644 index 000000000..b19d16b73 --- /dev/null +++ b/netbox/netbox/urls.py @@ -0,0 +1,32 @@ +from django.conf.urls import include, url +from django.contrib import admin +from django.views.defaults import page_not_found + +from views import home, trigger_500 +from users.views import login, logout + + +urlpatterns = [ + url(r'^$', home, name='home'), + url(r'^circuits/', include('circuits.urls', namespace='circuits')), + url(r'^dcim/', include('dcim.urls', namespace='dcim')), + url(r'^ipam/', include('ipam.urls', namespace='ipam')), + url(r'^secrets/', include('secrets.urls', namespace='secrets')), + url(r'^profile/', include('users.urls', namespace='users')), + + url(r'^login/$', login, name='login'), + url(r'^logout/$', logout, name='logout'), + + url(r'^api/circuits/', include('circuits.api.urls', namespace='circuits-api')), + url(r'^api/dcim/', include('dcim.api.urls', namespace='dcim-api')), + url(r'^api/ipam/', include('ipam.api.urls', namespace='ipam-api')), + url(r'^api/secrets/', include('secrets.api.urls', namespace='secrets-api')), + url(r'^api/docs/', include('rest_framework_swagger.urls')), + url(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), + + # Error testing + url(r'^404/$', page_not_found), + url(r'^500/$', trigger_500), + + url(r'^admin/', include(admin.site.urls)), +] diff --git a/netbox/netbox/views.py b/netbox/netbox/views.py new file mode 100644 index 000000000..a88361e3f --- /dev/null +++ b/netbox/netbox/views.py @@ -0,0 +1,45 @@ +from django.shortcuts import render + +from circuits.models import Provider, Circuit +from dcim.models import Site, Rack, Device, ConsolePort, PowerPort, InterfaceConnection +from ipam.models import Aggregate, Prefix, IPAddress, VLAN +from secrets.models import Secret + + +def home(request): + + stats = { + + # DCIM + 'site_count': Site.objects.count(), + 'rack_count': Rack.objects.count(), + 'device_count': Device.objects.count(), + 'interface_connections_count': InterfaceConnection.objects.count(), + 'console_connections_count': ConsolePort.objects.filter(cs_port__isnull=False).count(), + 'power_connections_count': PowerPort.objects.filter(power_outlet__isnull=False).count(), + + # IPAM + 'aggregate_count': Aggregate.objects.count(), + 'prefix_count': Prefix.objects.count(), + 'ipaddress_count': IPAddress.objects.count(), + 'vlan_count': VLAN.objects.count(), + + # Circuits + 'provider_count': Provider.objects.count(), + 'circuit_count': Circuit.objects.count(), + + # Secrets + 'secret_count': Secret.objects.count(), + + } + + return render(request, 'home.html', { + 'stats': stats, + }) + + +def trigger_500(request): + """Hot-wired method of triggering a server error to test reporting.""" + + raise Exception("Congratulations, you've triggered an exception! Go tell all your friends what an exceptional " + "person you are.") diff --git a/netbox/netbox/wsgi.py b/netbox/netbox/wsgi.py new file mode 100644 index 000000000..7fac23c61 --- /dev/null +++ b/netbox/netbox/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for do_ipam project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/1.8/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "netbox.settings") + +application = get_wsgi_application() diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css new file mode 100644 index 000000000..ab4858c6d --- /dev/null +++ b/netbox/project-static/css/base.css @@ -0,0 +1,224 @@ +/* Layout */ +* { + margin: 0; +} +html, body { + height: 100%; +} +body { + padding-top: 70px; +} +.container { + width: 1340px; +} +.wrapper { + min-height: 100%; + height: auto !important; + margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */ + padding-bottom: 30px; +} +.footer, .push { + height: 60px; /* .push must be the same height as .footer */ +} +.footer { + background-color: #f5f5f5; + border-top: 1px solid #d0d0d0; +} +footer p { + margin: 20px 0; +} + +/* Forms */ +label { + font-weight: normal; +} +label.required { + font-weight: bold; +} + +/* Tables */ +th.pk, td.pk { + width: 30px; +} + + +/* Paginator */ +nav ul.pagination { + margin-top: 0; +} + + +/* Racks */ +div.rack_header { + margin-left: 36px; + text-align: center; + width: 200px; +} +ul.rack_legend { + float: left; + list-style-type: none; + margin-right: 6px; + padding: 0; + width: 30px; +} +ul.rack_legend li { + color: #c0c0c0; + display: block; + font-size: 10px; + height: 20px; + overflow: hidden; + padding: 5px 0; + text-align: right; +} +div.rack_frame { + float: left; + position: relative; +} +ul.rack { + border: 2px solid #404040; + float: left; + list-style-type: none; + padding: 0; + position: absolute; + width: 200px; +} +ul.rack li { + display: block; + font-size: 13px; + height: 20px; + overflow: hidden; + text-align: center; +} +ul.rack_empty li { + background-color: #f7f7f7; + border-bottom: 1px solid #dddddd; + height: 20px; +} +ul.rack li.empty:last-child { + border-bottom: 0; +} +ul.rack_far_face { + z-index: 100; +} +ul.rack_near_face { + z-index: 200; +} +ul.rack li.h2u { height: 40px; } +ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; } +ul.rack li.h3u { height: 60px; } +ul.rack li.h3u a, ul.rack li.h3u span { padding: 20px 0; } +ul.rack li.h4u { height: 80px; } +ul.rack li.h4u a, ul.rack li.h4u span { padding: 30px 0; } +ul.rack li.h5u { height: 100px; } +ul.rack li.h5u a, ul.rack li.h5u span { padding: 40px 0; } +ul.rack li.h6u { height: 120px; } +ul.rack li.h6u a, ul.rack li.h6u span { padding: 50px 0; } +ul.rack li.h7u { height: 140px; } +ul.rack li.h7u a, ul.rack li.h7u span { padding: 60px 0; } +ul.rack li.h8u { height: 160px; } +ul.rack li.h8u a, ul.rack li.h8u span { padding: 70px 0; } +ul.rack li.h9u { height: 180px; } +ul.rack li.h9u a, ul.rack li.h9u span { padding: 80px 0; } +ul.rack li.h10u { height: 200px; } +ul.rack li.h10u a, ul.rack li.h10u span { padding: 90px 0; } +ul.rack li.h11u { height: 220px; } +ul.rack li.h11u a, ul.rack li.h11u span { padding: 100px 0; } +ul.rack li.h12u { height: 240px; } +ul.rack li.h12u a, ul.rack li.h12u span { padding: 110px 0; } +ul.rack li.h13u { height: 260px; } +ul.rack li.h13u a, ul.rack li.h13u span { padding: 120px 0; } +ul.rack li.h14u { height: 280px; } +ul.rack li.h14u a, ul.rack li.h14u span { padding: 130px 0; } +ul.rack li.h15u { height: 300px; } +ul.rack li.h15u a, ul.rack li.h15u span { padding: 140px 0; } +ul.rack li.h16u { height: 320px; } +ul.rack li.h16u a, ul.rack li.h16u span { padding: 150px 0; } +ul.rack li.occupied a { + color: #ffffff; + display: block; + font-weight: bold; +} +ul.rack li.occupied a:hover { + text-decoration: none; +} +ul.rack li.occupied span { + display: block; +} +ul.rack_near_face li { + border-bottom: 1px solid #e0e0e0; +} +ul.rack_near_face li.occupied { + color: #474747; +} +ul.rack_far_face li.occupied { + background: repeating-linear-gradient( + 45deg, + #f7f7f7, + #f7f7f7 7px, + #f0f0f0 7px, + #f0f0f0 14px + ); + color: #303030; +} +ul.rack_far_face li.blocked { + background: repeating-linear-gradient( + 45deg, + #f7f7f7, + #f7f7f7 7px, + #ffc7c7 7px, + #ffc7c7 14px + ); + color: #303030; +} +ul.rack_near_face li.empty a { + color: #0000ff; + display: none; + text-decoration: none; +} +ul.rack_near_face li.empty:hover { + background-color: #ffffff; +} +ul.rack_near_face li.empty:hover a { + display: block; +} + +/* Rack elevation colors (from http://flatuicolors.com) */ +.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; } +.teal:hover { background-color: #16a085; } +.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; } +.green:hover { background-color: #27ae60; } +.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; } +.blue:hover { background-color: #2980b9; } +.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; } +.purple:hover { background-color: #8e44ad; } +.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; } +.yellow:hover { background-color: #f39c12; } +.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; } +.orange:hover { background-color: #d35400; } +.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; } +.red:hover { background-color: #c0392b; } +.light_gray { background-color: #ecf0f1; border-bottom: 1px solid #bdc3c7; } +.light_gray:hover { background-color: #bdc3c7; } +.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; } +.medium_gray:hover { background-color: #7f8c8d; } +.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; } +.dark_gray:hover { background-color: #2c3e50; } + +/* Misc */ +.panel table>thead>tr>th { + border-bottom: 0; +} +ul.nav-tabs, ul.nav-pills { + margin-bottom: 20px; +} +.panel .list-group { + max-height: 400px; + overflow: auto; +} +/* Fix progress bar margin inside table cells */ +td .progress { + margin-bottom: 0; +} +textarea { + font-family: Consolas, Lucida Console, monospace; +} diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js new file mode 100644 index 000000000..a6988e767 --- /dev/null +++ b/netbox/project-static/js/forms.js @@ -0,0 +1,116 @@ +$(document).ready(function() { + + // "Select all" checkbox in a table header + $('th input:checkbox').click(function (event) { + $(this).parents('table').find('td input:checkbox').prop('checked', $(this).prop('checked')); + }); + + // Helper select fields + $('select.helper-parent').change(function () { + + // Resolve child field by ID specified in parent + var child_field = $('#id_' + $(this).attr('child')); + + // Wipe out any existing options within the child field + child_field.empty(); + child_field.append($("").attr("value", "").text("")); + + // If the parent has a value set, fetch a list of child options via the API and populate the child field with them + if ($(this).val()) { + + // Construct the API request URL + var api_url = $(this).attr('child-source'); + var parent_accessor = $(this).attr('parent-accessor'); + if (parent_accessor) { + api_url += '?' + parent_accessor + '=' + $(this).val(); + } else { + api_url += '?' + $(this).attr('name') + '_id=' + $(this).val(); + } + var api_url_extra = $(this).attr('child-filter'); + if (api_url_extra) { + api_url += '&' + api_url_extra; + } + + var disabled_indicator = $(this).attr('disabled-indicator'); + var disabled_exempt = child_field.attr('exempt'); + var child_display = $(this).attr('child-display'); + if (!child_display) { + child_display = 'name'; + } + + $.ajax({ + url: api_url, + dataType: 'json', + success: function (response, status) { + console.log(response); + $.each(response, function (index, choice) { + var option = $("").attr("value", choice.id).text(choice[child_display]); + if (disabled_indicator && choice[disabled_indicator] && choice.id != disabled_exempt) { + option.attr("disabled", "disabled") + } + child_field.append(option); + }); + } + }); + + } + + // Trigger change event in case the child field is the parent of another field + child_field.change(); + + }); + + // API select widget + $('select[filter-for]').change(function () { + + // Resolve child field by ID specified in parent + var child_name = $(this).attr('filter-for'); + var child_field = $('#id_' + child_name); + + // Wipe out any existing options within the child field + child_field.empty(); + child_field.append($("").attr("value", "").text("")); + + if ($(this).val()) { + + var api_url = child_field.attr('api-url'); + var disabled_indicator = child_field.attr('disabled-indicator'); + var initial_value = child_field.attr('initial'); + var display_field = child_field.attr('display-field') || 'name'; + + // Gather the values of all other filter fields for this child + $("select[filter-for='" + child_name + "']").each(function() { + var filter_field = $(this); + if (filter_field.val()) { + api_url = api_url.replace('{{' + filter_field.attr('name') + '}}', filter_field.val()); + } else { + // Not all filters have been selected yet + return false; + } + + }); + + // If all URL variables have been replaced, make the API call + if (api_url.search('{{') < 0) { + $.ajax({ + url: api_url, + dataType: 'json', + success: function (response, status) { + $.each(response, function (index, choice) { + var option = $("").attr("value", choice.id).text(choice[display_field]); + if (disabled_indicator && choice[disabled_indicator] && choice.id != initial_value) { + option.attr("disabled", "disabled") + } + child_field.append(option); + }); + } + }); + } + + } + + // Trigger change event in case the child field is the parent of another field + child_field.change(); + + }); +}); diff --git a/netbox/project-static/js/livesearch.js b/netbox/project-static/js/livesearch.js new file mode 100644 index 000000000..2f97a5654 --- /dev/null +++ b/netbox/project-static/js/livesearch.js @@ -0,0 +1,40 @@ +$(document).ready(function() { + var search_field = $('#id_livesearch'); + var search_key = search_field.attr('data-key'); + var label = search_field.attr('data-label'); + if (!label) { + label = 'name'; + } + + search_field.autocomplete({ + source: function(request, response) { + $.ajax({ + type: 'GET', + url: search_field.attr('data-source'), + data: search_key + '=' + request.term, + success: function(data) { + var choices = []; + $.each(data, function (index, choice) { + choices.push({ + value: choice.id, + label: choice[label] + }); + }); + response(choices); + } + }); + }, + select: function(event, ui) { + event.preventDefault(); + search_field.val(ui.item.label); + var real_field = $('#id_' + search_field.attr('data-field')); + real_field.empty(); + real_field.append($("").attr('value', ui.item.value).text(ui.item.label)); + real_field.change(); + // If the field has a parent helper, reset the parent to no selection + $('select[filter-for="' + real_field.attr('name') + '"]').val(''); + }, + minLength: 4, + delay: 500 + }); +}); diff --git a/netbox/project-static/js/secrets.js b/netbox/project-static/js/secrets.js new file mode 100644 index 000000000..264014715 --- /dev/null +++ b/netbox/project-static/js/secrets.js @@ -0,0 +1,96 @@ +$(document).ready(function() { + + // Unlocking a secret + $('button.unlock-secret').click(function (event) { + var secret_id = $(this).attr('secret-id'); + + // Retrieve from storage or prompt for private key + var private_key = sessionStorage.getItem('private_key'); + if (!private_key) { + $('#privkey_modal').modal('show'); + } else { + unlock_secret(secret_id, private_key); + $(this).hide(); + $(this).siblings('button.lock-secret').show(); + } + }); + + // Locking a secret + $('button.lock-secret').click(function (event) { + var secret_id = $(this).attr('secret-id'); + $('#secret_' + secret_id).html('********'); + $(this).hide(); + $(this).siblings('button.unlock-secret').show(); + }); + + // Adding/editing a secret + $('form.requires-private-key').submit(function(event) { + var private_key = sessionStorage.getItem('private_key'); + if (private_key) { + $('#id_private_key').val(private_key); + } else { + $('#privkey_modal').modal('show'); + return false; + } + }); + + // Prompt the user to enter a private RSA key for decryption + $('#submit_privkey').click(function() { + var private_key = $('#user_privkey').val(); + sessionStorage.setItem('private_key', private_key); + }); + + // Generate a new public/private key pair via the API + $('#generate_keypair').click(function() { + $('#new_keypair_modal').modal('show'); + $.ajax({ + url: '/api/secrets/generate-keys/', + type: 'GET', + dataType: 'json', + success: function (response, status) { + var public_key = response.public_key; + var private_key = response.private_key; + $('#new_pubkey').val(public_key); + $('#new_privkey').val(private_key); + }, + error: function (xhr, ajaxOptions, thrownError) { + alert("There was an error generating a new key pair."); + } + }); + }); + + // Enter a newly generated public key + $('#use_new_pubkey').click(function() { + var new_pubkey = $('#new_pubkey'); + if (new_pubkey.val()) { + $('#id_public_key').val(new_pubkey.val()); + } + }); + + // Retrieve a secret via the API + function unlock_secret(secret_id, private_key) { + var csrf_token = $('input[name=csrfmiddlewaretoken]').val(); + $.ajax({ + url: '/api/secrets/secrets/' + secret_id + '/decrypt/', + type: 'POST', + data: { + private_key: private_key + }, + dataType: 'json', + beforeSend: function(xhr, settings) { + xhr.setRequestHeader("X-CSRFToken", csrf_token); + }, + success: function (response, status) { + var secret_plaintext = response.plaintext; + $('#secret_' + secret_id).html(secret_plaintext); + return true; + }, + error: function (xhr, ajaxOptions, thrownError) { + if (xhr.status == 403) { + alert("Decryption failed: " + xhr.statusText); + } + } + }); + } + +}); diff --git a/netbox/secrets/__init__.py b/netbox/secrets/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/secrets/admin.py b/netbox/secrets/admin.py new file mode 100644 index 000000000..d9a425087 --- /dev/null +++ b/netbox/secrets/admin.py @@ -0,0 +1,71 @@ +from django.contrib import admin, messages +from django.shortcuts import redirect, render + +from .forms import ActivateUserKeyForm +from .models import UserKey, SecretRole, Secret + + +@admin.register(UserKey) +class UserKeyAdmin(admin.ModelAdmin): + actions = ['activate_selected'] + list_display = ['user', 'is_filled', 'is_active', 'created'] + fields = ['user', 'public_key', 'is_active', 'last_modified'] + readonly_fields = ['is_active', 'last_modified'] + + def get_readonly_fields(self, request, obj=None): + # Don't allow a user to modify an existing public key directly. + if obj and obj.public_key: + return ['public_key'] + self.readonly_fields + return self.readonly_fields + + def get_actions(self, request): + # Bulk deletion is disabled at the manager level, so remove the action from the admin site for this model. + actions = super(UserKeyAdmin, self).get_actions(request) + if 'delete_selected' in actions: + del actions['delete_selected'] + if not request.user.has_perm('secrets.activate_userkey'): + del actions['activate_selected'] + return actions + + def activate_selected(modeladmin, request, queryset): + """ + Enable bulk activation of UserKeys + """ + try: + my_userkey = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + messages.error(request, "You do not have an active User Key.") + return redirect('/admin/secrets/userkey/') + + if 'activate' in request.POST: + form = ActivateUserKeyForm(request.POST) + if form.is_valid(): + try: + master_key = my_userkey.get_master_key(form.cleaned_data['secret_key']) + for uk in form.cleaned_data['_selected_action']: + uk.activate(master_key) + return redirect('/admin/secrets/userkey/') + except ValueError: + messages.error(request, "Invalid private key provided. Unable to retrieve master key.") + else: + form = ActivateUserKeyForm(initial={'_selected_action': request.POST.getlist(admin.ACTION_CHECKBOX_NAME)}) + + return render(request, 'activate_keys.html', { + 'form': form, + }) + activate_selected.short_description = "Activate selected user keys" + + +@admin.register(SecretRole) +class SecretRoleAdmin(admin.ModelAdmin): + list_display = ['name', 'slug'] + prepopulated_fields = { + 'slug': ['name'], + } + + +@admin.register(Secret) +class SecretAdmin(admin.ModelAdmin): + list_display = ['parent', 'role', 'name', 'created', 'last_modified'] + fields = ['parent', 'role', 'name', 'hash', 'created', 'last_modified'] + readonly_fields = ['parent', 'hash', 'created', 'last_modified'] diff --git a/netbox/secrets/api/__init__.py b/netbox/secrets/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/secrets/api/serializers.py b/netbox/secrets/api/serializers.py new file mode 100644 index 000000000..462666fad --- /dev/null +++ b/netbox/secrets/api/serializers.py @@ -0,0 +1,39 @@ +from rest_framework import serializers + +from secrets.models import Secret, SecretRole + + +# +# SecretRoles +# + +class SecretRoleSerializer(serializers.ModelSerializer): + + class Meta: + model = SecretRole + fields = ['id', 'name', 'slug'] + + +class SecretRoleNestedSerializer(SecretRoleSerializer): + + class Meta(SecretRoleSerializer.Meta): + pass + + +# +# Secrets +# + +# TODO: Serialize parent info +class SecretSerializer(serializers.ModelSerializer): + role = SecretRoleNestedSerializer() + + class Meta: + model = Secret + fields = ['id', 'role', 'name', 'hash', 'created', 'last_modified'] + + +class SecretNestedSerializer(SecretSerializer): + + class Meta(SecretSerializer.Meta): + fields = ['id', 'name'] diff --git a/netbox/secrets/api/urls.py b/netbox/secrets/api/urls.py new file mode 100644 index 000000000..e38cf3393 --- /dev/null +++ b/netbox/secrets/api/urls.py @@ -0,0 +1,20 @@ +from django.conf.urls import url + +from .views import * + + +urlpatterns = [ + + # Secrets + url(r'^secrets/$', SecretListView.as_view(), name='secret_list'), + url(r'^secrets/(?P\d+)/$', SecretDetailView.as_view(), name='secret_detail'), + url(r'^secrets/(?P\d+)/decrypt/$', SecretDecryptView.as_view(), name='secret_decrypt'), + + # Secret roles + url(r'^secret-roles/$', SecretRoleListView.as_view(), name='secretrole_list'), + url(r'^secret-roles/(?P\d+)/$', SecretRoleDetailView.as_view(), name='secretrole_detail'), + + # Miscellaneous + url(r'^generate-keys/$', RSAKeyGeneratorView.as_view(), name='generate_keys'), + +] diff --git a/netbox/secrets/api/views.py b/netbox/secrets/api/views.py new file mode 100644 index 000000000..1f0e92765 --- /dev/null +++ b/netbox/secrets/api/views.py @@ -0,0 +1,104 @@ +from Crypto.PublicKey import RSA + +from django.http import HttpResponseForbidden +from django.shortcuts import get_object_or_404 + +from rest_framework import generics +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from secrets.models import Secret, SecretRole, UserKey +from .serializers import SecretRoleSerializer, SecretSerializer + + +class SecretRoleListView(generics.ListAPIView): + """ + List all secret roles + """ + queryset = SecretRole.objects.all() + serializer_class = SecretRoleSerializer + + +class SecretRoleDetailView(generics.RetrieveAPIView): + """ + Retrieve a single secret role + """ + queryset = SecretRole.objects.all() + serializer_class = SecretRoleSerializer + + +class SecretListView(generics.ListAPIView): + """ + List secrets (filterable) + """ + queryset = Secret.objects.select_related('role') + serializer_class = SecretSerializer + #filter_class = SecretFilter + permission_classes = [IsAuthenticated] + + +class SecretDetailView(generics.RetrieveAPIView): + """ + Retrieve a single Secret + """ + queryset = Secret.objects.select_related('role') + serializer_class = SecretSerializer + permission_classes = [IsAuthenticated] + + +class SecretDecryptView(APIView): + """ + Retrieve the plaintext from a stored Secret. The request must include a valid private key. + """ + permission_classes = [IsAuthenticated] + + def post(self, request, pk): + + secret = get_object_or_404(Secret, pk=pk) + private_key = request.POST.get('private_key') + if not private_key: + raise ValidationError("Private key is missing from request.") + + # Retrieve the Secret's plaintext with the user's private key + try: + uk = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + return HttpResponseForbidden(reason="No UserKey found.") + if not uk.is_active(): + return HttpResponseForbidden(reason="UserKey is inactive.") + + # Attempt to decrypt the Secret. + master_key = uk.get_master_key(private_key) + if master_key is None: + return HttpResponseForbidden(reason="Invalid secret key.") + secret.decrypt(master_key) + + return Response({ + 'plaintext': secret.plaintext, + }) + + +class RSAKeyGeneratorView(APIView): + """ + Generate a new RSA key pair for a user. Authenticated because it's a ripe avenue for DoS. + """ + permission_classes = [IsAuthenticated] + + def get(self, request): + + # Determine what size key to generate + key_size = request.GET.get('key_size', 2048) + if key_size not in range(2048, 4097, 256): + key_size = 2048 + + # Export RSA private and public keys in PEM format + key = RSA.generate(key_size) + private_key = key.exportKey('PEM') + public_key = key.publickey().exportKey('PEM') + + return Response({ + 'private_key': private_key, + 'public_key': public_key, + }) diff --git a/netbox/secrets/apps.py b/netbox/secrets/apps.py new file mode 100644 index 000000000..bc3714966 --- /dev/null +++ b/netbox/secrets/apps.py @@ -0,0 +1,7 @@ +from __future__ import unicode_literals + +from django.apps import AppConfig + + +class SecretsConfig(AppConfig): + name = 'secrets' diff --git a/netbox/secrets/decorators.py b/netbox/secrets/decorators.py new file mode 100644 index 000000000..ebbdae916 --- /dev/null +++ b/netbox/secrets/decorators.py @@ -0,0 +1,24 @@ +from django.contrib import messages +from django.shortcuts import redirect + +from .models import UserKey + + +def userkey_required(): + """ + Decorator for views which require that the user has an active UserKey (typically for encryption/decryption of + Secrets). + """ + def _decorator(view): + def wrapped_view(request, *args, **kwargs): + try: + uk = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + messages.warning(request, "This operation requires an active user key, but you don't have one.") + return redirect('users:userkey') + if not uk.is_active(): + messages.warning(request, "This operation is not available. Your user key has not been activated.") + return redirect('users:userkey') + return view(request, *args, **kwargs) + return wrapped_view + return _decorator diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py new file mode 100644 index 000000000..0773562e6 --- /dev/null +++ b/netbox/secrets/filters.py @@ -0,0 +1,21 @@ +import django_filters + +from .models import Secret, SecretRole + + +class SecretFilter(django_filters.FilterSet): + role_id = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=SecretRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=SecretRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) + + class Meta: + model = Secret + fields = ['name', 'role_id', 'role'] diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py new file mode 100644 index 000000000..a0f4d693c --- /dev/null +++ b/netbox/secrets/forms.py @@ -0,0 +1,146 @@ +from Crypto.Cipher import PKCS1_OAEP +from Crypto.PublicKey import RSA + +from django import forms +from django.apps import apps +from django.db.models import Count + +from utilities.forms import BootstrapMixin, ConfirmationForm, CSVDataField +from .models import Secret, SecretRole, UserKey + + +def validate_rsa_key(key, is_secret=True): + """ + Validate the format and type of an RSA key. + """ + try: + key = RSA.importKey(key) + except ValueError: + raise forms.ValidationError("Invalid RSA key. Please ensure that your key is in PEM (base64) format.") + except Exception as e: + raise forms.ValidationError("Invalid key detected: {}".format(e)) + if is_secret and not key.has_private(): + raise forms.ValidationError("This looks like a public key. Please provide your private RSA key.") + elif not is_secret and key.has_private(): + raise forms.ValidationError("This looks like a private key. Please provide your public RSA key.") + try: + PKCS1_OAEP.new(key) + except: + raise forms.ValidationError("Error validating RSA key. Please ensure that your key supports PKCS#1 OAEP.") + + +# +# Secrets +# + +class SecretForm(forms.ModelForm, BootstrapMixin): + private_key = forms.CharField(widget=forms.HiddenInput()) + plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext') + plaintext2 = forms.CharField(max_length=65535, required=False, label='Plaintext (verify)') + + class Meta: + model = Secret + fields = ['role', 'name', 'plaintext', 'plaintext2'] + + def clean(self): + validate_rsa_key(self.cleaned_data['private_key']) + + def clean_plaintext2(self): + plaintext = self.cleaned_data['plaintext'] + plaintext2 = self.cleaned_data['plaintext2'] + if plaintext != plaintext2: + raise forms.ValidationError("The two given plaintext values do not match. Please check your input.") + + +class SecretFromCSVForm(forms.ModelForm): + parent_name = forms.CharField() + role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid secret role.'}) + plaintext = forms.CharField() + + class Meta: + model = Secret + fields = ['parent_name', 'role', 'name', 'plaintext'] + + +class SecretImportForm(forms.Form, BootstrapMixin): + private_key = forms.CharField(widget=forms.HiddenInput()) + parent_type = forms.ChoiceField(label='Parent Type', choices=( + ('dcim.Device', 'Device'), + )) + csv = CSVDataField(csv_form=SecretFromCSVForm) + + def clean(self): + parent_type = self.cleaned_data.get('parent_type') + records = self.cleaned_data.get('csv') + if not records or not parent_type: + return + + secrets = [] + parent_cls = apps.get_model(parent_type) + + for i, record in enumerate(records, start=1): + secret_form = SecretFromCSVForm(data=record) + if secret_form.is_valid(): + s = secret_form.save(commit=False) + # Set parent + try: + s.parent = parent_cls.objects.get(name=secret_form.cleaned_data['parent_name']) + except parent_cls.DoesNotExist: + self.add_error('csv', "Invalid parent object ({})".format(secret_form.cleaned_data['parent_name'])) + # Set plaintext + s.plaintext = str(secret_form.cleaned_data['plaintext']) + secrets.append(s) + else: + for field, errors in secret_form.errors.items(): + for e in errors: + self.add_error('csv', "Record {} {}: {}".format(i, field, e)) + + self.cleaned_data['csv'] = secrets + + +class SecretBulkEditForm(forms.Form, BootstrapMixin): + pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) + role = forms.ModelChoiceField(queryset=SecretRole.objects.all()) + name = forms.CharField(max_length=100, required=False) + + +class SecretBulkDeleteForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) + + +def secret_role_choices(): + role_choices = SecretRole.objects.annotate(secret_count=Count('secrets')) + return [(r.slug, '{} ({})'.format(r.name, r.secret_count)) for r in role_choices] + + +class SecretFilterForm(forms.Form, BootstrapMixin): + role = forms.MultipleChoiceField(required=False, choices=secret_role_choices) + + +# +# UserKeys +# + +class UserKeyForm(forms.ModelForm, BootstrapMixin): + + class Meta: + model = UserKey + fields = ['public_key'] + help_texts = { + 'public_key': "Enter your public RSA key. Keep the private one with you; you'll need it for decryption.", + } + + def clean_public_key(self): + key = self.cleaned_data['public_key'] + + # Validate the RSA key format. + validate_rsa_key(key, is_secret=False) + + return key + + +class ActivateUserKeyForm(forms.Form): + _selected_action = forms.ModelMultipleChoiceField(queryset=UserKey.objects.all(), label='User Keys') + secret_key = forms.CharField(label='Your private key', widget=forms.Textarea(attrs={'class': 'vLargeTextField'})) + diff --git a/netbox/secrets/migrations/0001_initial.py b/netbox/secrets/migrations/0001_initial.py new file mode 100644 index 000000000..c2962a611 --- /dev/null +++ b/netbox/secrets/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-27 02:35 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Secret', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('name', models.CharField(blank=True, max_length=100)), + ('ciphertext', models.BinaryField(max_length=65568)), + ('hash', models.CharField(editable=False, max_length=128)), + ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Created')), + ('last_modified', models.DateTimeField(auto_now=True, verbose_name=b'Last modified')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), + ], + options={ + 'ordering': ['role', 'name'], + 'permissions': (('view_secret', 'Can view secrets'),), + }, + ), + migrations.CreateModel( + name='SecretRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.CreateModel( + name='UserKey', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('public_key', models.TextField(verbose_name=b'RSA public key')), + ('master_key_cipher', models.BinaryField(blank=True, max_length=512, null=True)), + ('created', models.DateTimeField(auto_now_add=True, verbose_name=b'Time created')), + ('last_modified', models.DateTimeField(auto_now=True, verbose_name=b'Last modified')), + ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='user_key', to=settings.AUTH_USER_MODEL, verbose_name=b'User')), + ], + options={ + 'ordering': ['user__username'], + 'permissions': (('activate_userkey', 'Can activate user keys for decryption'),), + }, + ), + migrations.AddField( + model_name='secret', + name='role', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='secrets', to='secrets.SecretRole'), + ), + ] diff --git a/netbox/secrets/migrations/__init__.py b/netbox/secrets/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py new file mode 100644 index 000000000..21f726357 --- /dev/null +++ b/netbox/secrets/models.py @@ -0,0 +1,282 @@ +import os +from Crypto.Cipher import AES, PKCS1_OAEP +from Crypto.PublicKey import RSA + +from django.conf import settings +from django.contrib.auth.hashers import make_password, check_password +from django.contrib.auth.models import User +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.core.urlresolvers import reverse +from django.db import models +from django.utils.encoding import force_bytes + + +def generate_master_key(): + """ + Generate a new 256-bit (32 bytes) AES key to be used for symmetric encryption of secrets. + """ + return os.urandom(32) + + +def encrypt_master_key(master_key, public_key): + """ + Encrypt a secret key with the provided public RSA key. + """ + key = RSA.importKey(public_key) + cipher = PKCS1_OAEP.new(key) + return cipher.encrypt(master_key) + + +def decrypt_master_key(master_key_cipher, private_key): + """ + Decrypt a secret key with the provided private RSA key. + """ + key = RSA.importKey(private_key) + cipher = PKCS1_OAEP.new(key) + return cipher.decrypt(master_key_cipher) + + +class UserKeyQuerySet(models.QuerySet): + + def active(self): + return self.filter(master_key_cipher__isnull=False) + + def delete(self): + # Disable bulk deletion to avoid accidentally wiping out all copies of the master key. + raise Exception("Bulk deletion has been disabled.") + + +class UserKey(models.Model): + """ + A user's personal public RSA key. + """ + user = models.OneToOneField(User, related_name='user_key', verbose_name='User') + public_key = models.TextField(verbose_name='RSA public key') + master_key_cipher = models.BinaryField(max_length=512, blank=True, null=True, editable=False) + created = models.DateTimeField(auto_now_add=True, verbose_name='Time created') + last_modified = models.DateTimeField(auto_now=True, verbose_name='Last modified') + + objects = UserKeyQuerySet.as_manager() + + class Meta: + ordering = ['user__username'] + permissions = ( + ('activate_userkey', "Can activate user keys for decryption"), + ) + + def __init__(self, *args, **kwargs): + super(UserKey, self).__init__(*args, **kwargs) + + # Store the initial public_key and master_key_cipher to check for changes on save(). + self.__initial_public_key = self.public_key + self.__initial_master_key_cipher = self.master_key_cipher + + def __unicode__(self): + return self.user.username + + def clean(self, *args, **kwargs): + + # Validate the public key format and length. + if self.public_key: + try: + pubkey = RSA.importKey(self.public_key) + except ValueError: + raise ValidationError("Invalid RSA key format.") + except: + raise ValidationError("Something went wrong while trying to save your key. Please ensure that you're " + "uploading a valid RSA public key in PEM format (no SSH/PGP).") + # key.size() returns 1 less than the key modulus + pubkey_length = pubkey.size() + 1 + if pubkey_length < settings.SECRETS_MIN_PUBKEY_SIZE: + raise ValidationError("Insufficient key length. Keys must be at least {} bits long." + .format(settings.SECRETS_MIN_PUBKEY_SIZE)) + # We can't use keys bigger than our master_key_cipher field can hold + if pubkey_length > 4096: + raise ValidationError("Public key size ({}) is too large. Maximum key size is 4096 bits." + .format(pubkey_length)) + + super(UserKey, self).clean() + + def save(self, *args, **kwargs): + + # Check whether public_key has been modified. If so, nullify the initial master_key_cipher. + if self.__initial_master_key_cipher and self.public_key != self.__initial_public_key: + self.master_key_cipher = None + + # If no other active UserKeys exist, generate a new master key and use it to activate this UserKey. + if self.is_filled() and not self.is_active() and not UserKey.objects.active().count(): + master_key = generate_master_key() + self.master_key_cipher = encrypt_master_key(master_key, self.public_key) + + super(UserKey, self).save(*args, **kwargs) + + def delete(self, *args, **kwargs): + + # If Secrets exist and this is the last active UserKey, prevent its deletion. Deleting the last UserKey will + # result in the master key being destroyed and rendering all Secrets inaccessible. + if Secret.objects.count() and [uk.pk for uk in UserKey.objects.active()] == [self.pk]: + raise Exception("Cannot delete the last active UserKey when Secrets exist! This would render all secrets " + "inaccessible.") + + super(UserKey, self).delete(*args, **kwargs) + + def is_filled(self): + """ + Returns True if the UserKey has been filled with a public RSA key. + """ + return bool(self.public_key) + is_filled.boolean = True + + def is_active(self): + """ + Returns True if the UserKey has been populated with an encrypted copy of the master key. + """ + return self.master_key_cipher is not None + is_active.boolean = True + + def get_master_key(self, private_key): + """ + Given the User's private key, return the encrypted master key. + """ + if not self.is_active: + raise ValueError("Unable to retrieve master key: UserKey is inactive.") + try: + return decrypt_master_key(force_bytes(self.master_key_cipher), private_key) + except ValueError: + return None + + def activate(self, master_key): + """ + Activate the UserKey by saving an encrypted copy of the master key to the database. + """ + if not self.public_key: + raise Exception("Cannot activate UserKey: Its public key must be filled first.") + self.master_key_cipher = encrypt_master_key(master_key, self.public_key) + self.save() + + +class SecretRole(models.Model): + """ + A functional classification of secret type. For example: login credentials, SNMP communities, etc. + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + +class Secret(models.Model): + """ + A secret string of up to 255 bytes in length, stored as both an AES256-encrypted ciphertext and an irreversible + salted SHA256 hash (for plaintext validation). + """ + content_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) + object_id = models.PositiveIntegerField() + parent = GenericForeignKey('content_type', 'object_id') + role = models.ForeignKey('SecretRole', related_name='secrets', on_delete=models.PROTECT) + name = models.CharField(max_length=100, blank=True) + ciphertext = models.BinaryField(editable=False, max_length=65568) # 16B IV + 2B pad length + {62-65550}B padded + hash = models.CharField(max_length=128, editable=False) + created = models.DateTimeField(auto_now_add=True, editable=False, verbose_name='Created') + last_modified = models.DateTimeField(auto_now=True, verbose_name='Last modified') + + plaintext = None + + class Meta: + ordering = ['role', 'name'] + permissions = ( + ('view_secret', "Can view secrets"), + ) + + def __init__(self, *args, **kwargs): + self.plaintext = kwargs.pop('plaintext', None) + super(Secret, self).__init__(*args, **kwargs) + + def __unicode__(self): + if self.role and self.parent: + return "{} for {}".format(self.role, self.parent) + return "Secret" + + def get_absolute_url(self): + return reverse('secrets:secret', args=[self.pk]) + + def _pad(self, s): + """ + Prepend the length of the plaintext (2B) and pad with garbage to a multiple of 16B (minimum of 64B). + +--+--------+-------------------------------------------+ + |LL|MySecret|xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx| + +--+--------+-------------------------------------------+ + """ + if len(s) > 65535: + raise ValueError("Maximum plaintext size is 65535 bytes.") + # Minimum ciphertext size is 64 bytes to conceal the length of short secrets. + if len(s) <= 62: + pad_length = 62 - len(s) + elif (len(s) + 2) % 16: + pad_length = 16 - ((len(s) + 2) % 16) + else: + pad_length = 0 + return chr(len(s) >> 8) + chr(len(s) % 256) + s + os.urandom(pad_length) + + def _unpad(self, s): + """ + Consume the first two bytes of s as a plaintext length indicator and return only that many bytes as the + plaintext. + """ + plaintext_length = (ord(s[0]) << 8) + ord(s[1]) + return s[2:plaintext_length + 2] + + def encrypt(self, secret_key): + """ + Generate a random initialization vector (IV) for AES. Pad the plaintext to the AES block size (16 bytes) and + encrypt. Prepend the IV for use in decryption. Finally, record the SHA256 hash of the plaintext for validation + upon decryption. + """ + if self.plaintext is None: + raise Exception("Must unlock or set plaintext before locking.") + + # Pad and encrypt plaintext + iv = os.urandom(16) + aes = AES.new(secret_key, AES.MODE_CFB, iv) + self.ciphertext = iv + aes.encrypt(self._pad(self.plaintext)) + + # Generate SHA256 using Django's built-in password hashing mechanism + self.hash = make_password(self.plaintext, hasher='pbkdf2_sha256') + + self.plaintext = None + + def decrypt(self, secret_key): + """ + Consume the first 16 bytes of self.ciphertext as the AES initialization vector (IV). The remainder is decrypted + using the IV and the provided secret key. Padding is then removed to reveal the plaintext. Finally, validate the + decrypted plaintext value against the stored hash. + """ + if self.plaintext is not None: + return + if not self.ciphertext: + raise Exception("Must define ciphertext before unlocking.") + + # Decrypt ciphertext and remove padding + iv = self.ciphertext[0:16] + aes = AES.new(secret_key, AES.MODE_CFB, iv) + plaintext = self._unpad(aes.decrypt(self.ciphertext[16:])) + + # Verify decrypted plaintext against hash + if not self.validate(plaintext): + raise ValueError("Invalid key or ciphertext!") + + self.plaintext = plaintext + + def validate(self, plaintext): + """ + Validate that a given plaintext matches the stored hash. + """ + if not self.hash: + raise Exception("Hash has not been generated for this secret.") + return check_password(plaintext, self.hash) diff --git a/netbox/secrets/tables.py b/netbox/secrets/tables.py new file mode 100644 index 000000000..b299ebd7d --- /dev/null +++ b/netbox/secrets/tables.py @@ -0,0 +1,31 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from .models import Secret + + +# +# Secrets +# + +class SecretTable(tables.Table): + parent = tables.LinkColumn('secrets:secret', args=[Accessor('pk')], verbose_name='Parent') + role = tables.Column(verbose_name='Role') + name = tables.Column(verbose_name='Name') + last_modified = tables.DateTimeColumn(verbose_name='Last modified') + + class Meta: + model = Secret + fields = ('parent', 'role', 'name', 'last_modified') + empty_text = "No secrets found." + attrs = { + 'class': 'table table-hover', + } + + +class SecretBulkEditTable(SecretTable): + pk = tables.CheckBoxColumn() + + class Meta(SecretTable.Meta): + model = None # django_tables2 bugfix + fields = ('pk', 'parent', 'role', 'name') diff --git a/netbox/secrets/templates/activate_keys.html b/netbox/secrets/templates/activate_keys.html new file mode 100644 index 000000000..93c0215e2 --- /dev/null +++ b/netbox/secrets/templates/activate_keys.html @@ -0,0 +1,12 @@ +{% extends "admin/base_site.html" %} + +{% block content %} + +
+ {% csrf_token %} + {{ form.as_p }} + + +
+ +{% endblock %} diff --git a/netbox/secrets/tests/__init__.py b/netbox/secrets/tests/__init__.py new file mode 100644 index 000000000..b04a14228 --- /dev/null +++ b/netbox/secrets/tests/__init__.py @@ -0,0 +1 @@ +from test_models import * diff --git a/netbox/secrets/tests/test_models.py b/netbox/secrets/tests/test_models.py new file mode 100644 index 000000000..8dfb39bd6 --- /dev/null +++ b/netbox/secrets/tests/test_models.py @@ -0,0 +1,131 @@ +from Crypto.PublicKey import RSA + +from django.conf import settings +from django.contrib.auth.models import User +from django.core.exceptions import ValidationError +from django.test import TestCase + +from secrets.models import UserKey, Secret, generate_master_key, encrypt_master_key, decrypt_master_key + + +class UserKeyTestCase(TestCase): + + def setUp(self): + self.TEST_KEYS = {} + key_size = getattr(settings, 'SECRETS_MIN_PUBKEY_SIZE', 2048) + for username in ['alice', 'bob']: + User.objects.create_user(username=username, password=username) + key = RSA.generate(key_size) + self.TEST_KEYS['{}_public'.format(username)] = key.publickey().exportKey('PEM') + self.TEST_KEYS['{}_private'.format(username)] = key.exportKey('PEM') + + def test_01_fill(self): + """ + Validate the filling of a UserKey with public key material. + """ + alice_uk = UserKey(user=User.objects.get(username='alice')) + self.assertFalse(alice_uk.is_filled(), "UserKey with empty public_key is_filled() did not return False") + alice_uk.public_key = self.TEST_KEYS['alice_public'] + self.assertTrue(alice_uk.is_filled(), "UserKey with public key is_filled() did not return True") + + def test_02_activate(self): + """ + Validate the activation of a UserKey. + """ + master_key = generate_master_key() + alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public']) + self.assertFalse(alice_uk.is_active(), "Inactive UserKey is_active() did not return False") + alice_uk.activate(master_key) + self.assertTrue(alice_uk.is_active(), "ActiveUserKey is_active() did not return True") + + def test_03_key_sizes(self): + """ + Ensure that RSA keys which are too small or too large are rejected. + """ + rsa = RSA.generate(getattr(settings, 'SECRETS_MIN_PUBKEY_SIZE', 2048) - 256) + small_key = rsa.publickey().exportKey('PEM') + try: + UserKey(public_key=small_key).clean() + self.fail("UserKey.clean() did not fail with an undersized RSA key") + except ValidationError: + pass + rsa = RSA.generate(4096 + 256) # Max size is 4096 (enforced by master_key_cipher field size) + big_key = rsa.publickey().exportKey('PEM') + try: + UserKey(public_key=big_key).clean() + self.fail("UserKey.clean() did not fail with an oversized RSA key") + except ValidationError: + pass + + def test_04_master_key_retrieval(self): + """ + Test the decryption of a master key using the user's private key. + """ + master_key = generate_master_key() + alice_uk = UserKey(user=User.objects.get(username='alice'), public_key=self.TEST_KEYS['alice_public']) + alice_uk.activate(master_key) + retrieved_master_key = alice_uk.get_master_key(self.TEST_KEYS['alice_private']) + self.assertEqual(master_key, retrieved_master_key, "Master key retrieval failed with correct private key") + + def test_05_invalid_private_key(self): + """ + Ensure that an exception is raised when attempting to retrieve a secret key using an invalid private key. + """ + secret_key = generate_master_key() + secret_key_cipher = encrypt_master_key(secret_key, self.TEST_KEYS['alice_public']) + try: + decrypted_secret_key = decrypt_master_key(secret_key_cipher, self.TEST_KEYS['bob_private']) + self.fail("Decrypting secret key from Alice's UserKey using Bob's private key did not fail") + except ValueError: + pass + + +class SecretTestCase(TestCase): + + def test_01_encrypt_decrypt(self): + """ + Test basic encryption and decryption functionality using a random master key. + """ + plaintext = "FooBar123" + secret_key = generate_master_key() + s = Secret(plaintext=plaintext) + s.encrypt(secret_key) + + # Ensure plaintext is deleted upon encryption + self.assertIsNone(s.plaintext, "Plaintext must be None after encrypting.") + + # Enforce minimum ciphertext length + self.assertGreaterEqual(len(s.ciphertext), 80, "Ciphertext must be at least 80 bytes (16B IV + 64B+ ciphertext") + + # Ensure proper hashing algorithm is used + hasher, iterations, salt, sha256 = s.hash.split('$') + self.assertEqual(hasher, 'pbkdf2_sha256', "Hashing algorithm has been modified to: {}".format(hasher)) + self.assertGreaterEqual(iterations, 24000, "Insufficient iteration count ({}) for hash".format(iterations)) + self.assertGreaterEqual(len(salt), 12, "Hash salt is too short ({} chars)".format(len(salt))) + + # Test hash validation + self.assertTrue(s.validate(plaintext), "Plaintext does not validate against the generated hash") + self.assertFalse(s.validate(""), "Empty plaintext validated against hash") + self.assertFalse(s.validate("Invalid plaintext"), "Invalid plaintext validated against hash") + + # Test decryption + s.decrypt(secret_key) + self.assertEqual(plaintext, s.plaintext, "Decrypting Secret returned incorrect plaintext") + + def test_02_ciphertext_uniqueness(self): + """ + Generate 50 Secrets using the same plaintext and check for duplicate IVs or payloads. + """ + plaintext = "1234567890abcdef" + secret_key = generate_master_key() + ivs = [] + ciphertexts = [] + for i in range(1, 51): + s = Secret(plaintext=plaintext) + s.encrypt(secret_key) + ivs.append(s.ciphertext[0:16]) + ciphertexts.append(s.ciphertext[16:32]) + duplicate_ivs = [i for i, x in enumerate(ivs) if ivs.count(x) > 1] + self.assertEqual(duplicate_ivs, [], "One or more duplicate IVs found!") + duplicate_ciphertexts = [i for i, x in enumerate(ciphertexts) if ciphertexts.count(x) > 1] + self.assertEqual(duplicate_ciphertexts, [], "One or more duplicate ciphertexts (first blocks) found!") diff --git a/netbox/secrets/urls.py b/netbox/secrets/urls.py new file mode 100644 index 000000000..085e4d940 --- /dev/null +++ b/netbox/secrets/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^secrets/$', views.secret_list, name='secret_list'), + url(r'^secrets/import/$', views.secret_import, name='secret_import'), + url(r'^secrets/edit/$', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'), + url(r'^secrets/delete/$', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'), + url(r'^secrets/(?P\d+)/$', views.secret, name='secret'), + url(r'^secrets/(?P\d+)/edit/$', views.secret_edit, name='secret_edit'), + url(r'^secrets/(?P\d+)/delete/$', views.secret_delete, name='secret_delete'), +] diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py new file mode 100644 index 000000000..275b89462 --- /dev/null +++ b/netbox/secrets/views.py @@ -0,0 +1,235 @@ +from django.apps import apps +from django.conf import settings +from django.contrib import messages +from django.contrib.auth.decorators import permission_required, login_required +from django.contrib.auth.mixins import PermissionRequiredMixin +from django.core.urlresolvers import reverse +from django.db import transaction, IntegrityError +from django.db.models import ProtectedError +from django.shortcuts import get_object_or_404, redirect, render + +from django_tables2 import RequestConfig +from utilities.error_handlers import handle_protectederror +from utilities.forms import ConfirmationForm +from utilities.paginator import EnhancedPaginator +from utilities.views import BulkEditView, BulkDeleteView + +from .decorators import userkey_required +from .filters import SecretFilter +from .forms import SecretForm, SecretImportForm, SecretBulkEditForm, SecretBulkDeleteForm, SecretFilterForm +from .models import Secret, UserKey +from .tables import SecretTable, SecretBulkEditTable + + +# +# Secrets +# + +@login_required +def secret_list(request): + + queryset = Secret.objects.select_related('role').prefetch_related('parent') + queryset = SecretFilter(request.GET, queryset).qs + + if request.user.has_perm('secrets.change_secret') or request.user.has_perm('secrets.delete_secret'): + secret_table = SecretBulkEditTable(queryset) + else: + secret_table = SecretTable(queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\ + .configure(secret_table) + + return render(request, 'secrets/secret_list.html', { + 'secret_table': secret_table, + 'filter_form': SecretFilterForm(request.GET, label_suffix=''), + }) + + +@login_required +def secret(request, pk): + + secret = get_object_or_404(Secret, pk=pk) + + return render(request, 'secrets/secret.html', { + 'secret': secret, + }) + + +@permission_required('secrets.add_secret') +@userkey_required() +def secret_add(request, parent_model, parent_pk): + + # Retrieve parent object + parent_cls = apps.get_model(parent_model) + parent = get_object_or_404(parent_cls, pk=parent_pk) + + secret = Secret(parent=parent) + uk = UserKey.objects.get(user=request.user) + + if request.method == 'POST': + form = SecretForm(request.POST, instance=secret) + if form.is_valid(): + + # Retrieve the master key from the current user's UserKey + master_key = uk.get_master_key(form.cleaned_data['private_key']) + if master_key is None: + form.add_error(None, "Invalid private key! Unable to encrypt secret data.") + + # Create and encrypt the new Secret + else: + secret = form.save(commit=False) + secret.plaintext = str(form.cleaned_data['plaintext']) + secret.encrypt(master_key) + secret.save() + + messages.success(request, "Added new secret: {0}".format(secret)) + if '_addanother' in request.POST: + return redirect('secrets:secret_add') + else: + return redirect('secrets:secret', pk=secret.pk) + + else: + form = SecretForm(instance=secret) + + return render(request, 'secrets/secret_edit.html', { + 'secret': secret, + 'form': form, + 'cancel_url': parent.get_absolute_url(), + }) + + +@permission_required('secrets.change_secret') +@userkey_required() +def secret_edit(request, pk): + + secret = get_object_or_404(Secret, pk=pk) + uk = UserKey.objects.get(user=request.user) + + if request.method == 'POST': + form = SecretForm(request.POST, instance=secret) + if form.is_valid(): + + # Re-encrypt the Secret if a plaintext has been specified. + if form.cleaned_data['plaintext']: + + # Retrieve the master key from the current user's UserKey + master_key = uk.get_master_key(form.cleaned_data['private_key']) + if master_key is None: + form.add_error(None, "Invalid private key! Unable to encrypt secret data.") + + # Create and encrypt the new Secret + else: + secret = form.save(commit=False) + secret.plaintext = str(form.cleaned_data['plaintext']) + secret.encrypt(master_key) + secret.save() + + else: + secret = form.save() + + messages.success(request, "Modified secret {0}".format(secret)) + return redirect('secrets:secret', pk=secret.pk) + + else: + form = SecretForm(instance=secret) + + return render(request, 'secrets/secret_edit.html', { + 'secret': secret, + 'form': form, + 'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk}), + }) + + +@permission_required('secrets.delete_secret') +def secret_delete(request, pk): + + secret = get_object_or_404(Secret, pk=pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + try: + secret.delete() + messages.success(request, "Secret {0} has been deleted".format(secret)) + return redirect('secrets:secret_list') + except ProtectedError, e: + handle_protectederror(secret, request, e) + return redirect('secrets:secret', pk=secret.pk) + + else: + form = ConfirmationForm() + + return render(request, 'secrets/secret_delete.html', { + 'secret': secret, + 'form': form, + 'cancel_url': reverse('secrets:secret', kwargs={'pk': secret.pk}) + }) + + +@permission_required('secrets.add_secret') +@userkey_required() +def secret_import(request): + + uk = UserKey.objects.get(user=request.user) + + if request.method == 'POST': + form = SecretImportForm(request.POST) + if form.is_valid(): + + new_secrets = [] + + # Retrieve the master key from the current user's UserKey + master_key = uk.get_master_key(form.cleaned_data['private_key']) + if master_key is None: + form.add_error(None, "Invalid private key! Unable to encrypt secret data.") + + else: + try: + with transaction.atomic(): + for secret in form.cleaned_data['csv']: + secret.encrypt(master_key) + secret.save() + new_secrets.append(secret) + + secret_table = SecretTable(new_secrets) + messages.success(request, "Imported {} new secrets".format(len(new_secrets))) + + return render(request, 'import_success.html', { + 'secret_table': secret_table, + }) + + except IntegrityError as e: + form.add_error('csv', "Record {}: {}".format(len(new_secrets) + 1, e.__cause__)) + + else: + form = SecretImportForm() + + return render(request, 'secrets/secret_import.html', { + 'form': form, + 'cancel_url': reverse('secrets:secret_list'), + }) + + +class SecretBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'secrets.change_secret' + cls = Secret + form = SecretBulkEditForm + template_name = 'secrets/secret_bulk_edit.html' + redirect_url = 'secrets:secret_list' + + def update_objects(self, pk_list, form): + + fields_to_update = {} + for field in ['role', 'name']: + if form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + + updated_count = self.cls.objects.filter(pk__in=pk_list).update(**fields_to_update) + messages.success(self.request, "Updated {} secrets".format(updated_count)) + + +class SecretBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'secrets.delete_secret' + cls = Secret + form = SecretBulkDeleteForm + template_name = 'secrets/secret_bulk_delete.html' + redirect_url = 'secrets:secret_list' diff --git a/netbox/templates/500.html b/netbox/templates/500.html new file mode 100644 index 000000000..a2e0fba96 --- /dev/null +++ b/netbox/templates/500.html @@ -0,0 +1,29 @@ + + + + + Server Error + + + + + +
+
+
+
+ Server Error +
+
+

There was a problem with your request. This error has been logged and administrative staff have + been notified. Please return to the home page and try again.

+
+ Home Page +
+
+
+
+
+ + + diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html new file mode 100644 index 000000000..485cd14e9 --- /dev/null +++ b/netbox/templates/_base.html @@ -0,0 +1,197 @@ + + + + NetBox - {% block title %}Home{% endblock %} + + + + + + + +
+ {% if settings.MAINTENANCE_MODE %} + + {% endif %} + {% for message in messages %} + + {% endfor %} + {% block content %}{% endblock %} +
+
+
+
+
+
+

{{ settings.HOSTNAME }}

+
+
+

+ API · + Code +

+
+
+
+
+ + + +{% block javascript %}{% endblock %} + + diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html new file mode 100644 index 000000000..654819470 --- /dev/null +++ b/netbox/templates/circuits/circuit.html @@ -0,0 +1,102 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ circuit.provider }} Circuit {{ circuit.cid }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+ {% if perms.circuits.change_circuit %} + + + Edit this circuit + + {% endif %} + {% if perms.circuits.delete_circuit %} + + + Delete this circuit + + {% endif %} +
+

{{ circuit.provider }} Circuit {{ circuit.cid }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Provider + {{ circuit.provider }} +
Circuit ID{{ circuit.cid }}
Site + {{ circuit.site }} +
Termination + {% if circuit.interface %} + {{ circuit.interface.device }} {{ circuit.interface }} + {% else %} + Not defined + {% endif %} +
Install Date{{ circuit.install_date }}
Port Speed{{ circuit.get_port_speed_display }}
Commit Rate{{ circuit.commit_rate }}
Cross-Connect{{ circuit.xconnect_id }}
Patch Panel/Port{{ circuit.pp_info }}
+
+
+
+
+
+ Comments +
+
+ {% if circuit.comments %} + {{ circuit.comments|gfm }} + {% else %} + None + {% endif %} +
+
+
+
+{% endblock %} diff --git a/netbox/templates/circuits/circuit_bulk_delete.html b/netbox/templates/circuits/circuit_bulk_delete.html new file mode 100644 index 000000000..b1fb7677d --- /dev/null +++ b/netbox/templates/circuits/circuit_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Circuits?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these circuits? +

+
    + {% for circuit in selected_objects %} +
  • {{ circuit }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/circuits/circuit_bulk_edit.html b/netbox/templates/circuits/circuit_bulk_edit.html new file mode 100644 index 000000000..d37fe5dfb --- /dev/null +++ b/netbox/templates/circuits/circuit_bulk_edit.html @@ -0,0 +1,17 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Circuit Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for circuit in selected_objects %} + + {{ circuit }} + {{ circuit.type }} + {{ circuit.provider }} + {{ circuit.port_speed }} + {{ circuit.commit_rate }} + {{ circuit.comments }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/circuits/circuit_delete.html b/netbox/templates/circuits/circuit_delete.html new file mode 100644 index 000000000..a9f0b0382 --- /dev/null +++ b/netbox/templates/circuits/circuit_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete {{ circuit.provider }} Circuit {{ circuit.cid }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete rack {{ circuit }} from {{ circuit.site }}?

+{% endblock %} diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html new file mode 100644 index 000000000..d314d665a --- /dev/null +++ b/netbox/templates/circuits/circuit_edit.html @@ -0,0 +1,89 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if circuit %}Editing {{ circuit }}{% else %}Add a circuit{% endif %}{% endblock %} + +{% block content %} +{% if circuit %} +

Editing {{ circuit.provider }} circuit {{ circuit.cid }}

+{% else %} +

Add a Circuit

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
Details
+
+ {% render_field form.cid %} + {% render_field form.type %} + {% render_field form.provider %} + {% render_field form.install_date %} + {% render_field form.port_speed %} + {% render_field form.commit_rate %} + {% render_field form.xconnect_id %} + {% render_field form.pp_info %} +
+
+
+
+
+
Termination
+
+ {% render_field form.site %} + +
+
+ {% render_field form.rack %} + {% render_field form.device %} +
+ +
+ {% render_field form.interface %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+
+
+
+
+ {% if circuit %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/circuits/circuit_import.html b/netbox/templates/circuits/circuit_import.html new file mode 100644 index 000000000..b581cd33f --- /dev/null +++ b/netbox/templates/circuits/circuit_import.html @@ -0,0 +1,82 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Circuit Import{% endblock %} + +{% block content %} +

Circuit Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
Circuit IDAlphanumeric circuit identifierIC-603122
ProviderName of circuit providerTeliaSonera
TypeCircuit typeTransit
SiteSite nameASH-4
Install DateDate in YYYY-MM-DD format (optional)2016-02-23
Port SpeedSpeed in Mbps (optional)10000
Commit rateSpeed in Mbps (optional)2000
Cross-connect IDID of cross-connect (optional)937649
Patch PanelPatch panel/port ID (optional)PP8371 ports 13/14
+

Example

+
IC-603122,TeliaSonera,Transit,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14
+
+
+{% endblock %} diff --git a/netbox/templates/circuits/circuit_list.html b/netbox/templates/circuits/circuit_list.html new file mode 100644 index 000000000..26fc96ff1 --- /dev/null +++ b/netbox/templates/circuits/circuit_list.html @@ -0,0 +1,54 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Circuits{% endblock %} + +{% block content %} +
+ {% if perms.circuits.add_circuit %} + + + Add a circuit + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Circuits

+
+
+ {% include 'circuits/inc/circuit_table.html' with table=circuit_table %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_table.html b/netbox/templates/circuits/inc/circuit_table.html new file mode 100644 index 000000000..4590552d4 --- /dev/null +++ b/netbox/templates/circuits/inc/circuit_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.circuits.change_circuit or perms.circuits.delete_circuit %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.circuits.change_circuit %} + + {% endif %} + {% if perms.circuits.delete_circuit %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/circuits/inc/provider_table.html b/netbox/templates/circuits/inc/provider_table.html new file mode 100644 index 000000000..4fc803d8f --- /dev/null +++ b/netbox/templates/circuits/inc/provider_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.circuits.change_provider or perms.circuits.delete_provider %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.circuits.change_provider %} + + {% endif %} + {% if perms.circuits.delete_provider %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html new file mode 100644 index 000000000..b6250d1a2 --- /dev/null +++ b/netbox/templates/circuits/provider.html @@ -0,0 +1,104 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}{{ provider }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+ {% if perms.circuits.change_provider %} + + + Edit this provider + + {% endif %} + {% if perms.circuits.delete_provider %} + + + Delete this provider + + {% endif %} +
+

{{ provider }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + + + + + +
ASN{{ provider.asn }}
Account{{ provider.account }}
Customer Portal + {{ provider.portal_url }} +
NOC Contact{{ provider.noc_contact|linebreaksbr }}
Admin Contact{{ provider.admin_contact|linebreaksbr }}
+
+
+
+ Comments +
+
+ {% if provider.comments %} + {{ provider.comments|gfm }} + {% else %} + None + {% endif %} +
+
+
+
+
+
+ Circuits +
+ + {% for c in circuits %} + + + + + + + {% empty %} + + + + {% endfor %} +
+ {{ c.cid }} + + {{ c.site }} + + {% if c.interface %} + {{ c.interface.device }} + {% endif %} + {{ c.get_port_speed_display }}
None
+
+
+
+{% endblock %} diff --git a/netbox/templates/circuits/provider_bulk_delete.html b/netbox/templates/circuits/provider_bulk_delete.html new file mode 100644 index 000000000..ca9487e84 --- /dev/null +++ b/netbox/templates/circuits/provider_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Providers?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these providers? +

+
    + {% for provider in selected_objects %} +
  • {{ provider }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/circuits/provider_bulk_edit.html b/netbox/templates/circuits/provider_bulk_edit.html new file mode 100644 index 000000000..a318847ae --- /dev/null +++ b/netbox/templates/circuits/provider_bulk_edit.html @@ -0,0 +1,15 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Provider Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for provider in selected_objects %} + + {{ provider }} + {{ aggregate.asn }} + {{ aggregate.account }} + {{ aggregate.comments }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/circuits/provider_delete.html b/netbox/templates/circuits/provider_delete.html new file mode 100644 index 000000000..a64af7495 --- /dev/null +++ b/netbox/templates/circuits/provider_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete provider {{ provider }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete provider {{ provider }}?

+{% endblock %} diff --git a/netbox/templates/circuits/provider_edit.html b/netbox/templates/circuits/provider_edit.html new file mode 100644 index 000000000..e7613cf8c --- /dev/null +++ b/netbox/templates/circuits/provider_edit.html @@ -0,0 +1,69 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if provider %}Editing {{ provider }}{% else %}Add a provider{% endif %}{% endblock %} + +{% block content %} +{% if provider %} +

Editing {{ provider }}

+{% else %} +

Add a Provider

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
Provider
+
+ {% render_field form.name %} + {% render_field form.slug %} + {% render_field form.category %} + {% render_field form.asn %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+
+
+
+
Support Info
+
+ {% render_field form.account %} + {% render_field form.portal_url %} + {% render_field form.noc_contact %} + {% render_field form.admin_contact %} +
+
+
+
+
+
+ {% if provider %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/circuits/provider_import.html b/netbox/templates/circuits/provider_import.html new file mode 100644 index 000000000..d197c648f --- /dev/null +++ b/netbox/templates/circuits/provider_import.html @@ -0,0 +1,62 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Provider Import{% endblock %} + +{% block content %} +

Provider Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
NameProvider's proper nameLevel 3
SlugURL-friendly namelevel3
ASNAutonomous system number (optional)3356
AccountAccount number (optional)08931544
Portal URLCustomer service portal URL (optional)https://mylevel3.net
+

Example

+
Level 3,level3,3356,08931544,https://mylevel3.net
+
+
+{% endblock %} diff --git a/netbox/templates/circuits/provider_list.html b/netbox/templates/circuits/provider_list.html new file mode 100644 index 000000000..dc8fdbc5d --- /dev/null +++ b/netbox/templates/circuits/provider_list.html @@ -0,0 +1,33 @@ +{% extends '_base.html' %} + +{% block title %}Providers{% endblock %} + +{% block content %} +
+ {% if perms.circuits.add_provider %} + + + Add a provider + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Providers

+
+
+ {% include 'circuits/inc/provider_table.html' with table=provider_table %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/_rack_elevation.html b/netbox/templates/dcim/_rack_elevation.html new file mode 100644 index 000000000..002b9dd38 --- /dev/null +++ b/netbox/templates/dcim/_rack_elevation.html @@ -0,0 +1,49 @@ +
    + {% for u in rack.units %} +
  • {{ u }}
  • + {% endfor %} +
+ +
+ + +
    + {% for u in rack.units %} +
  • + {% endfor %} +
+ + +
    + {% for u in secondary_face %} + {% if u.device %} +
  • + {% else %} +
  • + {% endif %} + {% endfor %} +
+ + +
    + {% for u in primary_face %} + {% if u.device %} +
  • + {% ifequal u.device.face face_id %} + {{ u.device.name|default:u.device.device_role }} + {% else %} + {{ u.device.name|default:u.device.device_role }} + {% endifequal %} +
  • + {% else %} +
  • + {% if perms.dcim.add_device %} + add device + {% endif %} +
  • + {% endif %} + {% endfor %} +
+ +
diff --git a/netbox/templates/dcim/console_connections_import.html b/netbox/templates/dcim/console_connections_import.html new file mode 100644 index 000000000..6b47ba3bb --- /dev/null +++ b/netbox/templates/dcim/console_connections_import.html @@ -0,0 +1,61 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Console Connections Import{% endblock %} + +{% block content %} +

Console Connections Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
Console serverDevice name or {ID}abc1-cs3
Console server portFull CS port namePort 35
DeviceDevice name or {ID}abc1-switch7
Console PortConsole port nameConsole
Connection Status"planned" or "connected"planned
+

Example

+
abc1-cs3,Port 35,abc1-switch7,Console,planned
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/console_connections_list.html b/netbox/templates/dcim/console_connections_list.html new file mode 100644 index 000000000..9fafb906e --- /dev/null +++ b/netbox/templates/dcim/console_connections_list.html @@ -0,0 +1,31 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}Console Connections{% endblock %} + +{% block content %} +
+ {% if export_templates %} +
+ + +
+ {% endif %} +
+

Console Connections

+
+
+ {% render_table table 'table.html' %} +
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/consoleport_connect.html b/netbox/templates/dcim/consoleport_connect.html new file mode 100644 index 000000000..eed8cd701 --- /dev/null +++ b/netbox/templates/dcim/consoleport_connect.html @@ -0,0 +1,53 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Connect {{ consoleport.device }} {{ consoleport }}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Connect {{ consoleport.device }} {{ consoleport }}
+
+ +
+ +
+ {% render_field form.rack %} + {% render_field form.console_server %} +
+
+ {% render_field form.cs_port %} + {% render_field form.connection_status %} +
+
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/consoleport_delete.html b/netbox/templates/dcim/consoleport_delete.html new file mode 100644 index 000000000..0e31999d0 --- /dev/null +++ b/netbox/templates/dcim/consoleport_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete console port {{ consoleport }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this console port from {{ consoleport.device }}?

+{% endblock %} diff --git a/netbox/templates/dcim/consoleport_disconnect.html b/netbox/templates/dcim/consoleport_disconnect.html new file mode 100644 index 000000000..dfd5cf2e7 --- /dev/null +++ b/netbox/templates/dcim/consoleport_disconnect.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Disconnect console port {{ consoleport }}?{% endblock %} + +{% block message %} +

Are you sure you want to disconnect this console port from {{ consoleport.cs_port.device }} {{ consoleport.cs_port }}?

+{% endblock %} diff --git a/netbox/templates/dcim/consoleport_edit.html b/netbox/templates/dcim/consoleport_edit.html new file mode 100644 index 000000000..ebec708b0 --- /dev/null +++ b/netbox/templates/dcim/consoleport_edit.html @@ -0,0 +1,51 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if consoleport.pk %}Editing {{ consoleport.device }} {{ consoleport }}{% else %}Add a Console Port ({{ device }}){% endif %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ {% if consoleport.pk %} + Editing {{ consoleport }} + {% else %} + Add a Console Port + {% endif %} +
+
+
+ +
+

{% if consoleport %}{{ consoleport.device }}{% else %}{{ device }}{% endif %}

+
+
+ {% render_form form %} +
+
+
+
+ {% if consoleport.pk %} + + {% else %} + + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_connect.html b/netbox/templates/dcim/consoleserverport_connect.html new file mode 100644 index 000000000..e2c110afc --- /dev/null +++ b/netbox/templates/dcim/consoleserverport_connect.html @@ -0,0 +1,53 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Connect {{ consoleserverport.device }} {{ consoleserverport }}{% endblock %} + +{% block content %} +
+{% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Connect {{ consoleserverport.device }} {{ consoleserverport }}
+
+ +
+ +
+ {% render_field form.rack %} + {% render_field form.device %} +
+
+ {% render_field form.port %} + {% render_field form.connection_status %} +
+
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_delete.html b/netbox/templates/dcim/consoleserverport_delete.html new file mode 100644 index 000000000..8f645dbe8 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete console server port {{ consoleserverport }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this console server port from {{ consoleserverport.device }}?

+{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_disconnect.html b/netbox/templates/dcim/consoleserverport_disconnect.html new file mode 100644 index 000000000..5c0594464 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport_disconnect.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Disconnect {{ consoleserverport.device }} {{ consoleserverport }}?{% endblock %} + +{% block message %} +

Are you sure you want to disconnect {{ consoleserverport.connected_console.device }} {{ consoleserverport.connected_console }} from this port?

+{% endblock %} diff --git a/netbox/templates/dcim/consoleserverport_edit.html b/netbox/templates/dcim/consoleserverport_edit.html new file mode 100644 index 000000000..21871c477 --- /dev/null +++ b/netbox/templates/dcim/consoleserverport_edit.html @@ -0,0 +1,51 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if consoleserverport.pk %}Editing {{ consoleserverport.device }} {{ consoleserverport }}{% else %}Add a Console Server Port ({{ device }}){% endif %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ {% if consoleserverport.pk %} + Editing {{ consoleserverport }} + {% else %} + Add a Console Server Port + {% endif %} +
+
+
+ +
+

{% if consoleserverport %}{{ consoleserverport.device }}{% else %}{{ device }}{% endif %}

+
+
+ {% render_form form %} +
+
+
+
+ {% if consoleserverport.pk %} + + {% else %} + + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html new file mode 100644 index 000000000..45a33b1bd --- /dev/null +++ b/netbox/templates/dcim/device.html @@ -0,0 +1,426 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block title %}{{ device }}{% endblock %} + +{% block content %} +{% include 'dcim/inc/_device_header.html' with active_tab='info' %} +
+
+
+
+ Hardware +
+ + + + + + + + + + + + + + + + + + + + + +
Site + {{ device.rack.site }} +
Rack + {{ device.rack.name }}{% if device.rack.facility_id %} ({{ device.rack.facility_id }}){% endif %} +
Position + {% if device.position %} + U{{ device.position }} / {{ device.get_face_display }} + {% elif device.device_type.u_height %} + Not racked + {% else %} + N/A + {% endif %} +
Device Type + {{ device.device_type }} ({{ device.device_type.u_height }}U) +
Serial + {% if device.serial %} + {{ device.serial }} + {% else %} + Not defined + {% endif %} +
+
+
+
+ Management +
+ + + + + + + + + + + + + + + + + + + + + +
Role + {{ device.device_role }} +
Platform + {% if device.platform %} + {{ device.platform }} + {% else %} + Not assigned + {% endif %} +
Status + {% if device.status %} + {{ device.get_status_display }} + {% else %} + {{ device.get_status_display }} + {% endif %} +
Primary IP + {% if device.primary_ip %} + {{ device.primary_ip.address.ip }} + {% if device.primary_ip.nat_inside %} + (NAT for {{ device.primary_ip.nat_inside.address.ip }}) + {% endif %} + {% else %} + Not defined + {% endif %} +
SNMP String (RO) + {% if device.ro_snmp %} + {{ device.ro_snmp }} + {% else %} + Not defined + {% endif %} +
+
+ {% if perms.secrets.view_secret %} +
+
+ Secrets +
+ {% if secrets %} + + {% for secret in secrets %} + {% include 'secrets/inc/secret_tr.html' %} + {% endfor %} +
+ {% else %} +
+ None found +
+ {% endif %} + {% if perms.secrets.add_secret %} +
+ {% csrf_token %} +
+ + {% endif %} +
+ {% endif %} +
+
+ IP Addresses +
+ {% if ip_addresses %} + + {% for ip in ip_addresses %} + {% include 'dcim/inc/_ipaddress.html' %} + {% endfor %} +
+ {% else %} +
+ None found +
+ {% endif %} + {% if perms.ipam.add_ipaddress %} + + {% endif %} +
+
+
+ Critical Connections +
+ + {% for iface in mgmt_interfaces %} + {% include 'dcim/inc/_interface.html' with icon='wrench' %} + {% empty %} + + + + {% endfor %} + {% for cp in console_ports %} + {% include 'dcim/inc/_consoleport.html' %} + {% empty %} + + + + {% endfor %} + {% for pp in power_ports %} + {% include 'dcim/inc/_powerport.html' %} + {% empty %} + {% if not device.device_type.is_pdu %} + + + + {% endif %} + {% endfor %} +
+ No management interfaces defined! + {% if perms.dcim.add_interface %} + + {% endif %} +
+ No console ports defined! + {% if perms.dcim.add_consoleport %} + + {% endif %} +
+ No power ports defined! + {% if perms.dcim.add_powerport %} + + {% endif %} +
+ {% if perms.dcim.add_interface or perms.dcim.add_consoleport or perms.dcim.add_powerport %} + + {% endif %} +
+
+
+ Comments +
+
+ {% if device.comments %} + {{ device.comments|gfm }} + {% else %} + None + {% endif %} +
+
+
+
+ Related Devices +
+ {% if related_devices %} + + {% for rd in related_devices %} + + + + + + {% endfor %} +
+ {{ rd }} + + Rack {{ rd.rack }} + {{ rd.device_type }}
+ {% else %} +
None found
+ {% endif %} +
+
+
+ {% if interfaces or device.device_type.is_network_device %} +
+
+ {% if perms.dcim.add_interface %} + Add Interfaces + {% endif %} + Interfaces +
+ + {% for iface in interfaces %} + {% include 'dcim/inc/_interface.html' %} + {% empty %} + + + + {% endfor %} +
No interfaces defined
+
+ {% endif %} + {% if cs_ports or device.device_type.is_console_server %} +
+
+ {% if perms.dcim.add_consoleserverport %} + Add Console Server Ports + {% endif %} + Console Server Ports +
+ + {% for csp in cs_ports %} + {% include 'dcim/inc/_consoleserverport.html' %} + {% empty %} + + + + {% endfor %} +
No console server ports defined
+
+ {% endif %} + {% if power_outlets or device.device_type.is_pdu %} +
+
+ {% if perms.dcim.add_poweroutlet %} + Add Power Outlets + {% endif %} + Power Outlets +
+ + {% for po in power_outlets %} + {% include 'dcim/inc/_poweroutlet.html' %} + {% empty %} + + + + {% endfor %} +
No power outlets defined
+
+ {% endif %} +
+
+ + + +{% include 'secrets/inc/private_key_modal.html' %} +{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/device_bulk_delete.html b/netbox/templates/dcim/device_bulk_delete.html new file mode 100644 index 000000000..6bc5c18de --- /dev/null +++ b/netbox/templates/dcim/device_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Devices?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these devices? +

+
    + {% for device in selected_objects %} +
  • {{ device }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/dcim/device_bulk_edit.html b/netbox/templates/dcim/device_bulk_edit.html new file mode 100644 index 000000000..591a64585 --- /dev/null +++ b/netbox/templates/dcim/device_bulk_edit.html @@ -0,0 +1,16 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Device Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for device in selected_objects %} + + {{ device }} + {{ device.device_type }} + {{ device.device_role }} + {{ device.serial }} + {{ device.ro_snmp }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/dcim/device_delete.html b/netbox/templates/dcim/device_delete.html new file mode 100644 index 000000000..5d1b0a394 --- /dev/null +++ b/netbox/templates/dcim/device_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete device {{ device }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete {{ device }}?

+{% endblock %} diff --git a/netbox/templates/dcim/device_edit.html b/netbox/templates/dcim/device_edit.html new file mode 100644 index 000000000..86ab2be81 --- /dev/null +++ b/netbox/templates/dcim/device_edit.html @@ -0,0 +1,84 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if device %}Editing device {{ device }}{% else %}Add a device{% endif %}{% endblock %} + +{% block content %} +{% if device %} +

{{ device }}

+{% else %} +

Add a Device

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
Device
+
+ {% render_field form.name %} + {% render_field form.device_role %} +
+
+
+
Hardware
+
+ {% render_field form.manufacturer %} + {% render_field form.device_type %} + {% render_field form.serial %} +
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+
+
+
+
Location
+
+ {% render_field form.site %} + {% render_field form.rack %} + {% render_field form.face %} + {% render_field form.position %} +
+
+
+
Management
+
+ {% render_field form.platform %} + {% render_field form.status %} + {% if device %}{% render_field form.primary_ip %}{% endif %} + {% render_field form.ro_snmp %} +
+
+
+
+
+
+ {% if device %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/device_import.html b/netbox/templates/dcim/device_import.html new file mode 100644 index 000000000..e9eebeeae --- /dev/null +++ b/netbox/templates/dcim/device_import.html @@ -0,0 +1,85 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Device Import{% endblock %} + +{% block content %} +

Device Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
NameDevice name (optional)rack101_sw1
Device roleFunctional role of deviceToR Switch
Device manufacturerHardware manufacturerJuniper
Device modelHardware modelEX4300-48T
PlatformSoftware running on device (optional)Juniper Junos
SerialSerial number (optional)CAB00577291
SiteSite nameAshburn-VA
RackRack nameR101
Position (U)Numeric rack position (optional)21
FaceRack face; front or rear (optional)rear
+

Example

+
rack101_sw1,ToR Switch,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,rear
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/device_inventory.html b/netbox/templates/dcim/device_inventory.html new file mode 100644 index 000000000..3ead33f04 --- /dev/null +++ b/netbox/templates/dcim/device_inventory.html @@ -0,0 +1,66 @@ +{% extends '_base.html' %} + +{% block title %}{{ device }} - LLDP Neighbors{% endblock %} + +{% block content %} +{% include 'dcim/inc/_device_header.html' with active_tab='inventory' %} +
+
+
+
+ Chassis +
+ + + + + + + + + +
Model{{ device.device_type }}
Serial Number{{ device.serial }}
+
+
+
+ Software +
+ + + + + + + + + +
PackageVersion
+
+
+
+
+
+ Hardware +
+ + + + + + + + + + {% for module in modules %} + + + + + + {% endfor %} + +
ModulePart NumberSerial Number
{{ module.name }}{{ module.part_id }}{{ module.serial }}
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html new file mode 100644 index 000000000..2fe237f83 --- /dev/null +++ b/netbox/templates/dcim/device_list.html @@ -0,0 +1,58 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Devices{% endblock %} + +{% block content %} +
+ {% if perms.dcim.add_device %} + + + Add a device + + + + Import devices + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Devices

+
+
+ {% include 'dcim/inc/device_table.html' with table=device_table %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/device_lldp_neighbors.html b/netbox/templates/dcim/device_lldp_neighbors.html new file mode 100644 index 000000000..b37f1860e --- /dev/null +++ b/netbox/templates/dcim/device_lldp_neighbors.html @@ -0,0 +1,73 @@ +{% extends '_base.html' %} + +{% block title %}{{ device }} - LLDP Neighbors{% endblock %} + +{% block content %} +{% include 'dcim/inc/_device_header.html' with active_tab='lldp-neighbors' %} +
+
+ LLDP Neighbors +
+ + + + + + + + + + + + {% for iface in interfaces %} + + + {% if iface.connection %} + {% with iface.get_connected_interface as connected_iface %} + + + {% endwith %} + {% else %} + + {% endif %} + + + + {% endfor %} + +
InterfaceConfigured DeviceConfigured InterfaceLLDP DeviceLLDP Interface
{{ iface }} + {{ connected_iface.device }} + + {{ connected_iface }} + None
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/inc/_consoleport.html b/netbox/templates/dcim/inc/_consoleport.html new file mode 100644 index 000000000..21eb39b18 --- /dev/null +++ b/netbox/templates/dcim/inc/_consoleport.html @@ -0,0 +1,51 @@ + + + {{ cp.name }} + + {% if cp.cs_port %} + + {{ cp.cs_port.device }} + + + {{ cp.cs_port.name }} + + {% else %} + Not connected + {% endif %} + + {% if perms.dcim.change_consoleport %} + {% if cp.cs_port %} + {% if cp.connection_status %} + + + + {% else %} + + + + {% endif %} + + + + {% else %} + + + + {% endif %} + + + + {% endif %} + {% if perms.dcim.delete_consoleport %} + {% if cp.cs_port %} + + {% else %} + + + + {% endif %} + {% endif %} + + diff --git a/netbox/templates/dcim/inc/_consoleserverport.html b/netbox/templates/dcim/inc/_consoleserverport.html new file mode 100644 index 000000000..6c950f172 --- /dev/null +++ b/netbox/templates/dcim/inc/_consoleserverport.html @@ -0,0 +1,51 @@ + + + {{ csp.name }} + + {% if csp.connected_console %} + + {{ csp.connected_console.device }} + + + {{ csp.connected_console.name }} + + {% else %} + Not connected + {% endif %} + + {% if perms.dcim.change_consoleserverport %} + {% if csp.connected_console %} + {% if csp.connected_console.connection_status %} + + + + {% else %} + + + + {% endif %} + + + + {% else %} + + + + {% endif %} + + + + {% endif %} + {% if perms.dcim.delete_consoleserverport %} + {% if csp.connected_console %} + + {% else %} + + + + {% endif %} + {% endif %} + + diff --git a/netbox/templates/dcim/inc/_device_header.html b/netbox/templates/dcim/inc/_device_header.html new file mode 100644 index 000000000..c60a9ef63 --- /dev/null +++ b/netbox/templates/dcim/inc/_device_header.html @@ -0,0 +1,44 @@ +
+
+ {% if device.rack %} + + {% endif %} +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.dcim.change_device %} + + + Edit this device + + {% endif %} + {% if perms.dcim.delete_device %} + + + Delete this device + +{% endif %} +
+

{{ device }}

+ diff --git a/netbox/templates/dcim/inc/_interface.html b/netbox/templates/dcim/inc/_interface.html new file mode 100644 index 000000000..9be170f0b --- /dev/null +++ b/netbox/templates/dcim/inc/_interface.html @@ -0,0 +1,69 @@ + + + {{ iface.name }} + {% if iface.description %} + + {% endif %} + + {% if not iface.is_physical %} + Virtual + {% elif iface.connection %} + {% with iface.get_connected_interface as connected_iface %} + + {{ connected_iface.device }} + + + {{ connected_iface }} + + {% endwith %} + {% elif iface.circuit %} + + {{ iface.circuit }} + + {% else %} + Not connected + {% endif %} + + {% if iface.circuit or iface.connection %} + + {% endif %} + {% if perms.dcim.change_interface %} + {% if iface.is_physical %} + {% if iface.connection %} + {% if iface.connection.connection_status %} + + + + {% else %} + + + + {% endif %} + + + + {% else %} + + + + {% endif %} + {% endif %} + + + + {% endif %} + {% if perms.dcim.delete_interface %} + {% if iface.connection or iface.circuit %} + + {% else %} + + + + {% endif %} + {% endif %} + + diff --git a/netbox/templates/dcim/inc/_ipaddress.html b/netbox/templates/dcim/inc/_ipaddress.html new file mode 100644 index 000000000..8d159d1f2 --- /dev/null +++ b/netbox/templates/dcim/inc/_ipaddress.html @@ -0,0 +1,18 @@ + + {{ ip.interface }} + + {{ ip }} + + + {% if device.primary_ip == ip %} + Primary + {% endif %} + + + {% if perms.ipam.delete_ipaddress %} + + + + {% endif %} + + diff --git a/netbox/templates/dcim/inc/_poweroutlet.html b/netbox/templates/dcim/inc/_poweroutlet.html new file mode 100644 index 000000000..5ab4ccf95 --- /dev/null +++ b/netbox/templates/dcim/inc/_poweroutlet.html @@ -0,0 +1,51 @@ + + + {{ po.name }} + + {% if po.connected_port %} + + {{ po.connected_port.device }} + + + {{ po.connected_port.name }} + + {% else %} + Not connected + {% endif %} + + {% if perms.dcim.change_poweroutlet %} + {% if po.connected_port %} + {% if po.connected_port.connection_status %} + + + + {% else %} + + + + {% endif %} + + + + {% else %} + + + + {% endif %} + + + + {% endif %} + {% if perms.dcim.delete_poweroutlet %} + {% if po.connected_port %} + + {% else %} + + + + {% endif %} + {% endif %} + + diff --git a/netbox/templates/dcim/inc/_powerport.html b/netbox/templates/dcim/inc/_powerport.html new file mode 100644 index 000000000..1ef505530 --- /dev/null +++ b/netbox/templates/dcim/inc/_powerport.html @@ -0,0 +1,51 @@ + + + {{ pp.name }} + + {% if pp.power_outlet %} + + {{ pp.power_outlet.device }} + + + {{ pp.power_outlet.name }} + + {% else %} + Not connected + {% endif %} + + {% if perms.dcim.change_powerport %} + {% if pp.power_outlet %} + {% if pp.connection_status %} + + + + {% else %} + + + + {% endif %} + + + + {% else %} + + + + {% endif %} + + + + {% endif %} + {% if perms.dcim.delete_powerport %} + {% if pp.power_outlet %} + + {% else %} + + + + {% endif %} + {% endif %} + + diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html new file mode 100644 index 000000000..08a693cee --- /dev/null +++ b/netbox/templates/dcim/inc/device_table.html @@ -0,0 +1,27 @@ +{% load render_table from django_tables2 %} +{% if perms.dcim.add_interface or perms.dcim.change_device or perms.dcim.delete_device %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.dcim.add_interface %} + + {% endif %} + {% if perms.dcim.change_device %} + + {% endif %} + {% if perms.dcim.delete_device %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/dcim/inc/rack_table.html b/netbox/templates/dcim/inc/rack_table.html new file mode 100644 index 000000000..e9f0a3701 --- /dev/null +++ b/netbox/templates/dcim/inc/rack_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.dcim.change_rack or perms.dcim.delete_rack %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.dcim.change_rack %} + + {% endif %} + {% if perms.dcim.delete_rack %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/dcim/interface_bulk_add.html b/netbox/templates/dcim/interface_bulk_add.html new file mode 100644 index 000000000..40e220009 --- /dev/null +++ b/netbox/templates/dcim/interface_bulk_add.html @@ -0,0 +1,18 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Add Interfaces{% endblock %} + +{% block selected_objects_title %}Selected Devices{% endblock %} + +{% block form_title %}Interface(s) to Add{% endblock %} + +{% block select_objects_table %} + {% for device in selected_objects %} + + {{ device }} + {{ device.device_type }} + {{ device.device_role }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/dcim/interface_connections_import.html b/netbox/templates/dcim/interface_connections_import.html new file mode 100644 index 000000000..79fce2eb2 --- /dev/null +++ b/netbox/templates/dcim/interface_connections_import.html @@ -0,0 +1,61 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Interface Connections Import{% endblock %} + +{% block content %} +

Interface Connections Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
Device ADevice name or {ID}abc1-core1
Interface AInterface namexe-0/0/6
Device BDevice name or {ID}abc1-switch7
Interface BInterface namexe-0/0/0
Connection Status"planned" or "connected"planned
+

Example

+
abc1-core1,xe-0/0/6,abc1-switch7,xe-0/0/0,planned
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/interface_connections_list.html b/netbox/templates/dcim/interface_connections_list.html new file mode 100644 index 000000000..04badb393 --- /dev/null +++ b/netbox/templates/dcim/interface_connections_list.html @@ -0,0 +1,31 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}Interface Connections{% endblock %} + +{% block content %} +
+ {% if export_templates %} +
+ + +
+ {% endif %} +
+

Interface Connections

+
+
+ {% render_table table 'table.html' %} +
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/interface_delete.html b/netbox/templates/dcim/interface_delete.html new file mode 100644 index 000000000..2617afb77 --- /dev/null +++ b/netbox/templates/dcim/interface_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete interface {{ interface }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this interface from {{ interface.device }}?

+{% endblock %} diff --git a/netbox/templates/dcim/interface_edit.html b/netbox/templates/dcim/interface_edit.html new file mode 100644 index 000000000..098199184 --- /dev/null +++ b/netbox/templates/dcim/interface_edit.html @@ -0,0 +1,51 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if interface.pk %}Editing {{ interface.device }} {{ interface }}{% else %}Add an Interface ({{ device }}){% endif %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ {% if interface.pk %} + Editing {{ interface }} + {% else %} + Add an Interface + {% endif %} +
+
+
+ +
+

{% if interface %}{{ interface.device }}{% else %}{{ device }}{% endif %}

+
+
+ {% render_form form %} +
+
+
+
+ {% if interface.pk %} + + {% else %} + + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/interfaceconnection_delete.html b/netbox/templates/dcim/interfaceconnection_delete.html new file mode 100644 index 000000000..8cb08e1ad --- /dev/null +++ b/netbox/templates/dcim/interfaceconnection_delete.html @@ -0,0 +1,12 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete interface connection?{% endblock %} + +{% block message %} +

Are you sure you want to delete the connection between these two interfaces?

+
    +
  • {{ interfaceconnection.interface_a.device }}: {{ interfaceconnection.interface_a }}
  • +
  • {{ interfaceconnection.interface_b.device }}: {{ interfaceconnection.interface_b }}
  • +
+{% endblock %} diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html new file mode 100644 index 000000000..8d9a9acd8 --- /dev/null +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -0,0 +1,78 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Connect Interfaces{% endblock %} + +{% block content %} +

Connect Interfaces

+
+{% csrf_token %} +
+
+
+
+ A Side +
+
+
+ +
+

{{ device.rack }}

+
+
+
+ +
+

{{ device }}

+
+
+ {% render_field form.interface_a %} +
+
+
+
+ +
+
+
+
+ B Side +
+
+ +
+ +
+ {% render_field form.rack_b %} + {% render_field form.device_b %} +
+
+ {% render_field form.interface_b %} +
+
+
+
+
+
+ {% render_field form.connection_status %} +
+
+
+
+ + + Cancel +
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/ipaddress_assign.html b/netbox/templates/dcim/ipaddress_assign.html new file mode 100644 index 000000000..212a37458 --- /dev/null +++ b/netbox/templates/dcim/ipaddress_assign.html @@ -0,0 +1,37 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Add an IP Address{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ Add an IP Address +
+
+ {% render_form form %} +
+
+
+
+ + + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/power_connections_import.html b/netbox/templates/dcim/power_connections_import.html new file mode 100644 index 000000000..7c436508a --- /dev/null +++ b/netbox/templates/dcim/power_connections_import.html @@ -0,0 +1,61 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Power Connections Import{% endblock %} + +{% block content %} +

Power Connections Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
PDUDevice name or {ID}abc1-pdu1
Power OutletPower outlet nameAC4
DeviceDevice name or {ID}abc1-switch7
Power PortPower port namePSU0
Connection Status"planned" or "connected"connected
+

Example

+
abc1-pdu1,AC4,abc1-switch7,PSU0,connected
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/power_connections_list.html b/netbox/templates/dcim/power_connections_list.html new file mode 100644 index 000000000..063ca64e2 --- /dev/null +++ b/netbox/templates/dcim/power_connections_list.html @@ -0,0 +1,31 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}Power Connections{% endblock %} + +{% block content %} +
+ {% if export_templates %} +
+ + +
+ {% endif %} +
+

Power Connections

+
+
+ {% render_table table 'table.html' %} +
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_connect.html b/netbox/templates/dcim/poweroutlet_connect.html new file mode 100644 index 000000000..e8d467ea7 --- /dev/null +++ b/netbox/templates/dcim/poweroutlet_connect.html @@ -0,0 +1,53 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Connect {{ poweroutlet.device }} {{ poweroutlet }}{% endblock %} + +{% block content %} +
+{% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Connect {{ poweroutlet.device }} {{ poweroutlet }}
+
+ +
+ +
+ {% render_field form.rack %} + {% render_field form.device %} +
+
+ {% render_field form.port %} + {% render_field form.connection_status %} +
+
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_delete.html b/netbox/templates/dcim/poweroutlet_delete.html new file mode 100644 index 000000000..357a07bb4 --- /dev/null +++ b/netbox/templates/dcim/poweroutlet_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete power outlet {{ poweroutlet }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this power outlet from {{ poweroutlet.device }}?

+{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_disconnect.html b/netbox/templates/dcim/poweroutlet_disconnect.html new file mode 100644 index 000000000..81372033b --- /dev/null +++ b/netbox/templates/dcim/poweroutlet_disconnect.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Disconnect {{ poweroutlet.device }} {{ poweroutlet }}?{% endblock %} + +{% block message %} +

Are you sure you want to disconnect {{ poweroutlet.connected_port.device }} {{ poweroutlet.connected_port }} from this port?

+{% endblock %} diff --git a/netbox/templates/dcim/poweroutlet_edit.html b/netbox/templates/dcim/poweroutlet_edit.html new file mode 100644 index 000000000..9e83a9b53 --- /dev/null +++ b/netbox/templates/dcim/poweroutlet_edit.html @@ -0,0 +1,51 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if poweroutlet.pk %}Editing {{ poweroutlet.device }} {{ poweroutlet }}{% else %}Add a Power Outlet ({{ device }}){% endif %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ {% if poweroutlet.pk %} + Editing {{ poweroutlet }} + {% else %} + Add a Power Outlet + {% endif %} +
+
+
+ +
+

{% if poweroutlet %}{{ poweroutlet.device }}{% else %}{{ device }}{% endif %}

+
+
+ {% render_form form %} +
+
+
+
+ {% if poweroutlet.pk %} + + {% else %} + + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/powerport_connect.html b/netbox/templates/dcim/powerport_connect.html new file mode 100644 index 000000000..f3cc1b777 --- /dev/null +++ b/netbox/templates/dcim/powerport_connect.html @@ -0,0 +1,53 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Connect {{ powerport.device }} {{ powerport }}{% endblock %} + +{% block content %} +
+{% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Connect {{ powerport.device }} {{ powerport }}
+
+ +
+ +
+ {% render_field form.rack %} + {% render_field form.pdu %} +
+
+ {% render_field form.power_outlet %} + {% render_field form.connection_status %} +
+
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/dcim/powerport_delete.html b/netbox/templates/dcim/powerport_delete.html new file mode 100644 index 000000000..6f7aa3694 --- /dev/null +++ b/netbox/templates/dcim/powerport_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete power port {{ powerport }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this power port from {{ powerport.device }}?

+{% endblock %} diff --git a/netbox/templates/dcim/powerport_disconnect.html b/netbox/templates/dcim/powerport_disconnect.html new file mode 100644 index 000000000..f98694d9f --- /dev/null +++ b/netbox/templates/dcim/powerport_disconnect.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Disconnect power port {{ powerport }}?{% endblock %} + +{% block message %} +

Are you sure you want to disconnect this power port from {{ powerport.power_outlet.device }} {{ powerport.power_outlet }}?

+{% endblock %} diff --git a/netbox/templates/dcim/powerport_edit.html b/netbox/templates/dcim/powerport_edit.html new file mode 100644 index 000000000..4eeb940b4 --- /dev/null +++ b/netbox/templates/dcim/powerport_edit.html @@ -0,0 +1,51 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if powerport.pk %}Editing {{ powerport.device }} {{ powerport }}{% else %}Add a Power Port ({{ device }}){% endif %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+ {% if powerport.pk %} + Editing {{ powerport }} + {% else %} + Add a Power Port + {% endif %} +
+
+
+ +
+

{% if powerport %}{{ powerport.device }}{% else %}{{ device }}{% endif %}

+
+
+ {% render_form form %} +
+
+
+
+ {% if powerport.pk %} + + {% else %} + + + {% endif %} + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html new file mode 100644 index 000000000..5baafb663 --- /dev/null +++ b/netbox/templates/dcim/rack.html @@ -0,0 +1,147 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load render_table from django_tables2 %} + +{% block title %}Rack {{ rack }} ({{ rack.site }}){% endblock %} + +{% block content %} +
+
+ +
+
+
+ {% if prev_rack %} + + + Previous Rack + + {% endif %} + {% if next_rack %} + + + Next Rack + + {% endif %} + {% if perms.dcim.change_rack %} + + + Edit this rack + + {% endif %} + {% if perms.dcim.delete_rack %} + + + Delete this rack + + {% endif %} +
+

{{ rack.site }} / {{ rack.name }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + + + + + +
Site + {{ rack.site }} +
Group + {{ rack.group }} +
Facility ID + {% if rack.facility_id %} + {{ rack.facility_id }} + {% else %} + None + {% endif %} +
Height{{ rack.u_height }}U
Devices + {{ rack.devices.count }} +
+
+
+
+ Non-Racked Devices +
+ {% if nonracked_devices %} + + {% for device in nonracked_devices %} + + + + + + {% endfor %} +
+ {{ device.name }} + {{ device.device_role }}{{ device.device_type }}
+ {% else %} +
None
+ {% endif %} + {% if perms.dcim.add_device %} + + {% endif %} +
+
+
+ Comments +
+
+ {% if rack.comments %} + {{ rack.comments|gfm }} + {% else %} + None + {% endif %} +
+
+
+
+
+

Front

+
+ {% include 'dcim/_rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 %} +
+
+
+

Rear

+
+ {% include 'dcim/_rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 %} +
+
+{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/rack_bulk_delete.html b/netbox/templates/dcim/rack_bulk_delete.html new file mode 100644 index 000000000..d2002886e --- /dev/null +++ b/netbox/templates/dcim/rack_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Racks?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these racks? +

+
    + {% for rack in selected_objects %} +
  • {{ rack }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/dcim/rack_bulk_edit.html b/netbox/templates/dcim/rack_bulk_edit.html new file mode 100644 index 000000000..2d58a1556 --- /dev/null +++ b/netbox/templates/dcim/rack_bulk_edit.html @@ -0,0 +1,15 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Rack Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for rack in selected_objects %} + + {{ rack }} + {{ rack.facility_id }} + {{ rack.site }} + {{ rack.u_height }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/dcim/rack_delete.html b/netbox/templates/dcim/rack_delete.html new file mode 100644 index 000000000..39dc6c05e --- /dev/null +++ b/netbox/templates/dcim/rack_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete rack {{ rack }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete rack {{ rack }} from {{ rack.site }}?

+{% endblock %} diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html new file mode 100644 index 000000000..2c400ee85 --- /dev/null +++ b/netbox/templates/dcim/rack_edit.html @@ -0,0 +1,61 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if rack %}Editing rack {{ rack }}{% else %}Add a rack{% endif %}{% endblock %} + +{% block content %} +{% if rack %} +

Rack {{ rack }}

+{% else %} +

Add a Rack

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
Rack
+
+ {% render_field form.site %} + {% render_field form.group %} + {% render_field form.name %} + {% render_field form.facility_id %} + {% render_field form.u_height %} +
+
+
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+
+
+
+
+ {% if rack %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/rack_import.html b/netbox/templates/dcim/rack_import.html new file mode 100644 index 000000000..f370a6242 --- /dev/null +++ b/netbox/templates/dcim/rack_import.html @@ -0,0 +1,62 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Rack Import{% endblock %} + +{% block content %} +

Rack Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
SiteName of the assigned siteDC-4
GroupRack group name (optional)Cage 1400
NameInternal rack nameR101
Facility IDRack ID assigned by the facility (optional)J12.100
HeightHeight in rack units42
+

Example

+
DC-4,Cage 1400,R101,J12.100,42
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/rack_list.html b/netbox/templates/dcim/rack_list.html new file mode 100644 index 000000000..c905258c7 --- /dev/null +++ b/netbox/templates/dcim/rack_list.html @@ -0,0 +1,58 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Racks{% endblock %} + +{% block content %} +
+ {% if perms.dcim.add_rack %} + + + Add a rack + + + + Import racks + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Racks

+
+
+ {% include 'dcim/inc/rack_table.html' with table=rack_table %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html new file mode 100644 index 000000000..16d49477d --- /dev/null +++ b/netbox/templates/dcim/site.html @@ -0,0 +1,163 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block title %}{{ site }}{% endblock %} + +{% block content %} +
+ + {% if perms.dcim.change_site %} + + + Edit this site + + {% endif %} + {% if perms.dcim.delete_site %} + + + Delete this site + + {% endif %} +
+

{{ site.name }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + +
Facility{{ site.facility }}
AS Number{{ site.asn }}
Physical Address + {% if site.physical_address %} + + {% endif %} + {{ site.physical_address|linebreaksbr }} +
Shipping Address + {% if site.shipping_address %} + {{ site.shipping_address|linebreaksbr }} + {% else %} + See physical address + {% endif %} +
+
+
+
+ Comments +
+
+ {% if site.comments %} + {{ site.comments|gfm }} + {% else %} + None + {% endif %} +
+
+
+
+
+
+ Stats +
+ + + + + + + + + + + + + + + + + + + + + +
Racks + {{ stats.rack_count }} +
Devices + {{ stats.device_count }} +
Prefixes + {{ stats.prefix_count }} +
VLANs + {{ stats.vlan_count }} +
Circuits + {{ stats.circuit_count }} +
+
+
+
+ + +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/dcim/site_delete.html b/netbox/templates/dcim/site_delete.html new file mode 100644 index 000000000..e1a9d0d1d --- /dev/null +++ b/netbox/templates/dcim/site_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete site {{ site }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete site {{ site }}?

+{% endblock %} diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html new file mode 100644 index 000000000..85a3f56dc --- /dev/null +++ b/netbox/templates/dcim/site_edit.html @@ -0,0 +1,62 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if site %}Editing site {{ site }}{% else %}Add a site{% endif %}{% endblock %} + +{% block content %} +{% if site %} +

Site {{ site }}

+{% else %} +

Add a Site

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
Site
+
+ {% render_field form.name %} + {% render_field form.slug %} + {% render_field form.facility %} + {% render_field form.asn %} + {% render_field form.physical_address %} + {% render_field form.shipping_address %} +
+
+
+
+
+
Comments
+
+ {% render_field form.comments %} +
+
+
+
+
+
+ {% if site %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/site_import.html b/netbox/templates/dcim/site_import.html new file mode 100644 index 000000000..4c2c79fef --- /dev/null +++ b/netbox/templates/dcim/site_import.html @@ -0,0 +1,57 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Site Import{% endblock %} + +{% block content %} +

Site Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
NameSite's proper nameASH-4 South
SlugURL-friendly nameash4-south
FacilityName of the hosting facility (optional)Equinix DC6
ASNAutonomous system number (optional)65000
+

Example

+
ASH-4 South,ash4-south,Equinix DC6,65000
+
+
+{% endblock %} diff --git a/netbox/templates/dcim/site_list.html b/netbox/templates/dcim/site_list.html new file mode 100644 index 000000000..629d313f2 --- /dev/null +++ b/netbox/templates/dcim/site_list.html @@ -0,0 +1,30 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}Sites{% endblock %} + +{% block content %} +
+ {% if perms.dcim.add_site %} + + + Add a site + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Sites

+{% render_table site_table 'table.html' %} +{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html new file mode 100644 index 000000000..f11b72371 --- /dev/null +++ b/netbox/templates/home.html @@ -0,0 +1,146 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block content %} + +
+
+
+
+ DCIM +
+
+
+ {{ stats.site_count }} +

Sites

+

Geographic locations

+
+
+ {{ stats.rack_count }} +

Racks

+

Equipment racks, optionally organized by group

+
+
+ {{ stats.device_count }} +

Devices

+

Rack-mounted network equipment, servers, and other devices

+
+
+

Connections

+ {{ stats.interface_connections_count }} +

Interfaces

+ {{ stats.console_connections_count }} +

Console

+ {{ stats.power_connections_count }} +

Power

+
+
+
+
+
+
+
+ IPAM +
+
+
+ {{ stats.aggregate_count }} +

Aggregates

+

Top-level IP allocations

+
+
+ {{ stats.prefix_count }} +

Prefixes

+

IPv4 and IPv6 network assignments

+
+
+ {{ stats.ipaddress_count }} +

IP Addresses

+

Individual IPv4 and IPv6 addresses

+
+
+ {{ stats.vlan_count }} +

VLANs

+

Layer two domains, identified by VLAN ID

+
+
+
+
+
+
+
+ Circuits +
+
+
+ {{ stats.provider_count }} +

Providers

+

Organizations which provide circuit connectivity

+
+
+ {{ stats.circuit_count }} +

Circuits

+

Communication links for Internet transit, peering, and other services

+
+
+
+ {% if perms.secrets %} +
+
+ Secrets +
+
+
+ {{ stats.secret_count }} +

Secrets

+

Sensitive data (such as passwords) which has been stored securely

+
+
+
+ {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/import_success.html b/netbox/templates/import_success.html new file mode 100644 index 000000000..ec4dc1071 --- /dev/null +++ b/netbox/templates/import_success.html @@ -0,0 +1,13 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}Import Completed{% endblock %} + +{% block content %} +

Import Completed

+{% render_table table %} + + + Import more + +{% endblock %} diff --git a/netbox/templates/inc/filter_panel.html b/netbox/templates/inc/filter_panel.html new file mode 100644 index 000000000..1a7b70704 --- /dev/null +++ b/netbox/templates/inc/filter_panel.html @@ -0,0 +1,26 @@ +{% load form_helpers %} + +
+
+ Filter +
+
+
+ {% for field in filter_form %} +
+ {% if field|widget_type == 'checkboxinput' %} + + {% else %} + {{ field.label_tag }} + {{ field }} + {% endif %} +
+ {% endfor %} +
+ +
+
+
+
diff --git a/netbox/templates/ipam/aggregate.html b/netbox/templates/ipam/aggregate.html new file mode 100644 index 000000000..51f57a458 --- /dev/null +++ b/netbox/templates/ipam/aggregate.html @@ -0,0 +1,77 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}Aggregate: {{ aggregate }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+ {% if perms.ipam.change_aggregate %} + + + Edit this aggregate + + {% endif %} + {% if perms.ipam.delete_aggregate %} + + + Delete this aggregate + + {% endif %} +
+

{{ aggregate }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + +
Family{{ aggregate.get_family_display }}
RIR + {{ aggregate.rir }} +
Date Added + {% if aggregate.date_added %} + {{ aggregate.date_added|date }} + {% else %} + Not defined + {% endif %} +
Description + {% if aggregate.description %} + {{ aggregate.description }} + {% else %} + None + {% endif %} +
+
+
+
+
+
+ {% include 'ipam/inc/prefix_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/aggregate_bulk_delete.html b/netbox/templates/ipam/aggregate_bulk_delete.html new file mode 100644 index 000000000..14d5de7bf --- /dev/null +++ b/netbox/templates/ipam/aggregate_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Aggregates?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these aggregates? +

+
    + {% for aggregate in selected_objects %} +
  • {{ aggregate }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/ipam/aggregate_bulk_edit.html b/netbox/templates/ipam/aggregate_bulk_edit.html new file mode 100644 index 000000000..c61a94aca --- /dev/null +++ b/netbox/templates/ipam/aggregate_bulk_edit.html @@ -0,0 +1,15 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Aggregate Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for aggregate in selected_objects %} + + {{ aggregate }} + {{ aggregate.rir }} + {{ aggregate.date_added }} + {{ aggregate.description }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/ipam/aggregate_delete.html b/netbox/templates/ipam/aggregate_delete.html new file mode 100644 index 000000000..8f0c9635f --- /dev/null +++ b/netbox/templates/ipam/aggregate_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete aggregate {{ aggregate }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this aggregate?

+{% endblock %} diff --git a/netbox/templates/ipam/aggregate_edit.html b/netbox/templates/ipam/aggregate_edit.html new file mode 100644 index 000000000..36abbd33e --- /dev/null +++ b/netbox/templates/ipam/aggregate_edit.html @@ -0,0 +1,48 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Editing aggregate {{ aggregate }}{% endblock %} + +{% block content %} +{% if aggregate %} +

{{ aggregate }}

+{% else %} +

Add an Aggregate

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Aggregate
+
+ {% render_field form.prefix %} + {% render_field form.rir %} + {% render_field form.date_added %} + {% render_field form.description %} +
+
+
+
+ {% if aggregate %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/aggregate_import.html b/netbox/templates/ipam/aggregate_import.html new file mode 100644 index 000000000..51a0f95c2 --- /dev/null +++ b/netbox/templates/ipam/aggregate_import.html @@ -0,0 +1,57 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Aggregate Import{% endblock %} + +{% block content %} +

Aggregate Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
PrefixIPv4 or IPv6 network172.16.0.0/12
RIRName of RIRRFC 1918
Date AddedDate in YYYY-MM-DD format (optional)2016-02-23
DescriptionShort description (optional)Private IPv4 space
+

Example

+
172.16.0.0/12,RFC 1918,2016-02-23,Private IPv4 space
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/aggregate_list.html b/netbox/templates/ipam/aggregate_list.html new file mode 100644 index 000000000..1f057952a --- /dev/null +++ b/netbox/templates/ipam/aggregate_list.html @@ -0,0 +1,37 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Aggregates{% endblock %} + +{% block content %} +
+ {% if perms.ipam.add_aggregate %} + + + Add an aggregate + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Aggregates

+
+
+ {% include 'ipam/inc/aggregate_table.html' with table=aggregate_table %} +
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/inc/aggregate_table.html b/netbox/templates/ipam/inc/aggregate_table.html new file mode 100644 index 000000000..b9068271c --- /dev/null +++ b/netbox/templates/ipam/inc/aggregate_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.ipam.change_aggregate or perms.ipam.delete_aggregate %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.ipam.change_aggregate %} + + {% endif %} + {% if perms.ipam.delete_aggregate %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} \ No newline at end of file diff --git a/netbox/templates/ipam/inc/ipaddress_table.html b/netbox/templates/ipam/inc/ipaddress_table.html new file mode 100644 index 000000000..1994dece0 --- /dev/null +++ b/netbox/templates/ipam/inc/ipaddress_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.ipam.change_ipaddress or perms.ipam.delete_ipaddress %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.ipam.change_ipaddress %} + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} \ No newline at end of file diff --git a/netbox/templates/ipam/inc/prefix_header.html b/netbox/templates/ipam/inc/prefix_header.html new file mode 100644 index 000000000..69c5f6860 --- /dev/null +++ b/netbox/templates/ipam/inc/prefix_header.html @@ -0,0 +1,44 @@ +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_prefix %} + + + Edit this prefix + + {% endif %} + {% if perms.ipam.delete_prefix %} + + + Delete this prefix + + {% endif %} +
+

{{ prefix }}

+ diff --git a/netbox/templates/ipam/inc/prefix_table.html b/netbox/templates/ipam/inc/prefix_table.html new file mode 100644 index 000000000..e9ce60ab2 --- /dev/null +++ b/netbox/templates/ipam/inc/prefix_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.ipam.change_prefix or perms.ipam.delete_prefix %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.ipam.change_prefix %} + + {% endif %} + {% if perms.ipam.delete_prefix %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/ipam/inc/vlan_table.html b/netbox/templates/ipam/inc/vlan_table.html new file mode 100644 index 000000000..8570fad89 --- /dev/null +++ b/netbox/templates/ipam/inc/vlan_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.ipam.change_vlan or perms.ipam.delete_vlan %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.ipam.change_vlan %} + + {% endif %} + {% if perms.ipam.delete_vlan %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/ipam/inc/vrf_table.html b/netbox/templates/ipam/inc/vrf_table.html new file mode 100644 index 000000000..2290b0aaa --- /dev/null +++ b/netbox/templates/ipam/inc/vrf_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.ipam.change_vrf or perms.ipam.delete_vrf %} +
+ {% csrf_token %} + {% render_table vrf_table 'table.html' %} + {% if perms.ipam.change_vrf %} + + {% endif %} + {% if perms.ipam.delete_vrf %} + + {% endif %} +
+{% else %} + {% render_table vrf_table 'table.html' %} +{% endif %} diff --git a/netbox/templates/ipam/ipaddress.html b/netbox/templates/ipam/ipaddress.html new file mode 100644 index 000000000..35e5a6074 --- /dev/null +++ b/netbox/templates/ipam/ipaddress.html @@ -0,0 +1,138 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}{{ ipaddress }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_ipaddress %} + + + Edit this IP + + {% endif %} + {% if perms.ipam.delete_ipaddress %} + + + Delete this IP + + {% endif %} +
+

{{ ipaddress }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
Family{{ ipaddress.get_family_display }}
VRF{{ ipaddress.vrf|default:"Global" }}
Description + {% if ipaddress.description %} + {{ ipaddress.description }} + {% else %} + None + {% endif %} +
Assignment + {% if ipaddress.interface %} + {{ ipaddress.interface.device }} ({{ ipaddress.interface }}) + {% else %} + None + {% endif %} +
NAT (inside) + {% if ipaddress.nat_inside %} + {{ ipaddress.nat_inside }} + {% if ipaddress.nat_inside.interface %} + ({{ ipaddress.nat_inside.interface.device }}) + {% endif %} + {% else %} + None + {% endif %} +
NAT (outside) + {% if ipaddress.nat_outside %} + {{ ipaddress.nat_outside }} + {% else %} + None + {% endif %} +
+
+
+
+
+
+ Parent Prefixes +
+ {% if parent_prefixes %} + + {% for p in parent_prefixes %} + + + + + + + {% endfor %} +
+ {{ p }} + + {% if p.site %} + {{ p.site }} + {% endif %} + {{ p.status }}{{ p.role }}
+ {% else %} +
None
+ {% endif %} +
+ {% with heading='Related IP Addresses' %} + {% render_table related_ips_table 'panel_table.html' %} + {% endwith %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_bulk_delete.html b/netbox/templates/ipam/ipaddress_bulk_delete.html new file mode 100644 index 000000000..bc9786a3a --- /dev/null +++ b/netbox/templates/ipam/ipaddress_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete IP Addresses?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these IP addresses? +

+
    + {% for ipaddress in selected_objects %} +
  • {{ ipaddress }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_bulk_edit.html b/netbox/templates/ipam/ipaddress_bulk_edit.html new file mode 100644 index 000000000..8f53df143 --- /dev/null +++ b/netbox/templates/ipam/ipaddress_bulk_edit.html @@ -0,0 +1,16 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}IP Address Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for ipaddress in selected_objects %} + + {{ ipaddress }} + {{ ipaddress.vrf }} + {{ ipaddress.interface.device }} + {{ ipaddress.interface }} + {{ ipaddress.description }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_delete.html b/netbox/templates/ipam/ipaddress_delete.html new file mode 100644 index 000000000..5b08c7c75 --- /dev/null +++ b/netbox/templates/ipam/ipaddress_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete IP address {{ ipaddress }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this IP address?

+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_edit.html b/netbox/templates/ipam/ipaddress_edit.html new file mode 100644 index 000000000..0aa025e66 --- /dev/null +++ b/netbox/templates/ipam/ipaddress_edit.html @@ -0,0 +1,89 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if ipaddress %}Editing IP Address {{ ipaddress }}{% else %}Add an IP Address{% endif %}{% endblock %} + +{% block content %} +

{% if ipaddress %}Editing IP Address {{ ipaddress }}{% else %}Add an IP Address{% endif %}

+
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
IP Address
+
+ {% render_field form.address %} + {% render_field form.vrf %} + {% if ipaddress %} +
+ +
+

+ {% if ipaddress.interface %} + {{ ipaddress.interface.device }} + {% else %} + None + {% endif %} +

+
+
+
+ +
+

{{ ipaddress.interface }}

+
+
+ {% endif %} + {% render_field form.description %} +
+
+
+
+ {% if ipaddress %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+
+
+
NAT IP (Inside)
+
+ +
+
+ {% render_field form.nat_site %} + {% render_field form.nat_device %} +
+ +
+ {% render_field form.nat_inside %} +
+
+
+
+
+{% endblock %} + +{% block javascript %} + + +{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_import.html b/netbox/templates/ipam/ipaddress_import.html new file mode 100644 index 000000000..a415cb56a --- /dev/null +++ b/netbox/templates/ipam/ipaddress_import.html @@ -0,0 +1,67 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}IP Address Import{% endblock %} + +{% block content %} +

IP Address Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
AddressIPv4 or IPv6 address192.0.2.42/24
VRFVRF route distinguisher (optional)65000:123
DeviceDevice name (optional)switch12
InterfaceInterface name (optional)ge-0/0/31
Is PrimaryIf "true", IP will be primary for device (optional)True
DescriptionShort description (optional)Management IP
+

Example

+
192.0.2.42/24,65000:123,switch12,ge-0/0/31,True,Management IP
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/ipaddress_list.html b/netbox/templates/ipam/ipaddress_list.html new file mode 100644 index 000000000..12bad7554 --- /dev/null +++ b/netbox/templates/ipam/ipaddress_list.html @@ -0,0 +1,69 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load helpers %} + +{% block title %}IP Addresses{% endblock %} + +{% block content %} +
+ {% if perms.ipam.add_ipaddress %} + + + Add an IP + + + + Import IPs + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

IP Addresses

+
+
+ {% if not ip_table.rows and prefix_table.rows %} + + {% render_table prefix_table 'table.html' %} + {% else %} + {% include 'ipam/inc/ipaddress_table.html' with table=ip_table %} + {% endif %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/prefix.html b/netbox/templates/ipam/prefix.html new file mode 100644 index 000000000..e0b82b750 --- /dev/null +++ b/netbox/templates/ipam/prefix.html @@ -0,0 +1,92 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}{{ prefix }}{% endblock %} + +{% block content %} +{% include 'ipam/inc/prefix_header.html' with active_tab='prefix' %} +
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Family{{ prefix.get_family_display }}
Aggregate + {% if aggregate %} + {{ aggregate.prefix }} ({{ aggregate.rir }}) + {% else %} + None + {% endif %} +
Site + {% if prefix.site %} + {{ prefix.site }} + {% else %} + Not assigned + {% endif %} +
VLAN + {% if prefix.vlan %} + {{ prefix.vlan.name }} ({{ prefix.vlan.vid }}) + {% else %} + Not assigned + {% endif %} +
Status + {{ prefix.status }} +
Role{{ prefix.role }}
Description + {% if prefix.description %} + {{ prefix.description }} + {% else %} + None + {% endif %} +
IP Addresses{{ ipaddress_count }}
+
+
+
+ {% if duplicate_prefix_table.rows %} + {% with heading='Duplicate Prefixes' panel_class='danger' %} + {% render_table duplicate_prefix_table 'panel_table.html' %} + {% endwith %} + {% endif %} + {% with heading='Parent Prefixes' %} + {% render_table parent_prefix_table 'panel_table.html' %} + {% endwith %} +
+
+
+
+ {% include 'ipam/inc/prefix_table.html' with table=child_prefix_table table_template='panel_table.html' heading='Child Prefixes' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/prefix_bulk_delete.html b/netbox/templates/ipam/prefix_bulk_delete.html new file mode 100644 index 000000000..e0da3d310 --- /dev/null +++ b/netbox/templates/ipam/prefix_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Prefixes?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these prefixes? +

+
    + {% for prefix in selected_objects %} +
  • {{ prefix }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/ipam/prefix_bulk_edit.html b/netbox/templates/ipam/prefix_bulk_edit.html new file mode 100644 index 000000000..03e358725 --- /dev/null +++ b/netbox/templates/ipam/prefix_bulk_edit.html @@ -0,0 +1,17 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Prefix Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for prefix in selected_objects %} + + {{ prefix }} + {{ prefix.vrf|default:"Global" }} + {{ prefix.site }} + {{ prefix.status }} + {{ prefix.role }} + {{ prefix.description }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/ipam/prefix_delete.html b/netbox/templates/ipam/prefix_delete.html new file mode 100644 index 000000000..c769086df --- /dev/null +++ b/netbox/templates/ipam/prefix_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete prefix {{ prefix }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete this prefix?

+{% endblock %} diff --git a/netbox/templates/ipam/prefix_edit.html b/netbox/templates/ipam/prefix_edit.html new file mode 100644 index 000000000..2ecd7b8d6 --- /dev/null +++ b/netbox/templates/ipam/prefix_edit.html @@ -0,0 +1,51 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if prefix %}Editing prefix {{ prefix }}{% else %}Add a prefix{% endif %}{% endblock %} + +{% block content %} +{% if prefix %} +

{{ prefix }}

+{% else %} +

Add a Prefix

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Prefix
+
+ {% render_field form.prefix %} + {% render_field form.vrf %} + {% render_field form.site %} + {% render_field form.vlan %} + {% render_field form.status %} + {% render_field form.role %} + {% render_field form.description %} +
+
+
+
+ {% if prefix %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/prefix_import.html b/netbox/templates/ipam/prefix_import.html new file mode 100644 index 000000000..bf3199b71 --- /dev/null +++ b/netbox/templates/ipam/prefix_import.html @@ -0,0 +1,67 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Prefix Import{% endblock %} + +{% block content %} +

Prefix Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
PrefixIPv4 or IPv6 network192.168.42.0/24
VRFVRF route distinguisher (optional)65000:123
SiteName of assigned site (optional)HQ
StatusCurrent statusActive
RoleFunctional role (optional)Customer
DescriptionShort description (optional)7th floor WiFi
+

Example

+
192.168.42.0/24,65000:123,HQ,Active,Customer,7th floor WiFi
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/prefix_ipaddresses.html b/netbox/templates/ipam/prefix_ipaddresses.html new file mode 100644 index 000000000..eaba0a614 --- /dev/null +++ b/netbox/templates/ipam/prefix_ipaddresses.html @@ -0,0 +1,13 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}{{ prefix }}{% endblock %} + +{% block content %} +{% include 'ipam/inc/prefix_header.html' with active_tab='ip-addresses' %} +
+
+ {% include 'ipam/inc/ipaddress_table.html' with table=ip_table table_template='panel_table.html' heading='IP Addresses' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/prefix_list.html b/netbox/templates/ipam/prefix_list.html new file mode 100644 index 000000000..5e7169a9f --- /dev/null +++ b/netbox/templates/ipam/prefix_list.html @@ -0,0 +1,59 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block title %}Prefixes{% endblock %} + +{% block content %} +
+ {% if perms.ipam.add_prefix %} + + + Add a prefix + + + + Import prefixes + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

Prefixes

+
+
+ {% include 'ipam/inc/prefix_table.html' with table=prefix_table %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/vlan.html b/netbox/templates/ipam/vlan.html new file mode 100644 index 000000000..1e37dd985 --- /dev/null +++ b/netbox/templates/ipam/vlan.html @@ -0,0 +1,99 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}VLAN {{ vlan }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+
+ + + + +
+
+
+
+
+ {% if perms.ipam.change_vlan %} + + + Edit this VLAN + + {% endif %} + {% if perms.ipam.delete_vlan %} + + + Delete this VLAN + + {% endif %} +
+

{{ vlan }}

+
+
+
+
+ Details +
+ + + + + + + + + + + + + + + + + +
Site{{ vlan.site }}
VLAN ID{{ vlan.vid }}
Status + {{ vlan.status }} +
Role{{ vlan.role }}
+
+
+
+
+
+ Prefixes +
+ {% if prefixes %} + + {% for p in prefixes %} + + + + + + + {% endfor %} +
+ {{ p }} + + {% if p.site %} + {{ p.site }} + {% endif %} + {{ p.status }}{{ p.role }}
+ {% else %} +
None
+ {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/vlan_bulk_delete.html b/netbox/templates/ipam/vlan_bulk_delete.html new file mode 100644 index 000000000..c3d6bc0f1 --- /dev/null +++ b/netbox/templates/ipam/vlan_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete VLANs?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these VLANs? +

+
    + {% for vlan in selected_objects %} +
  • {{ vlan }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/ipam/vlan_bulk_edit.html b/netbox/templates/ipam/vlan_bulk_edit.html new file mode 100644 index 000000000..5e8b684d4 --- /dev/null +++ b/netbox/templates/ipam/vlan_bulk_edit.html @@ -0,0 +1,16 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}VLAN Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for vlan in selected_objects %} + + {{ vlan.vid }} + {{ vlan.name }} + {{ vlan.site }} + {{ vlan.status }} + {{ vlan.role }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/ipam/vlan_delete.html b/netbox/templates/ipam/vlan_delete.html new file mode 100644 index 000000000..7fc182caa --- /dev/null +++ b/netbox/templates/ipam/vlan_delete.html @@ -0,0 +1,22 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete VLAN {{ vlan }}?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete this VLAN? + {% if vlan.prefix_set.count %} + The following prefixes will also be deleted: + {% else %} + (There are no prefixes associated with this VLAN.) + {% endif %} +

+ {% if vlan.prefix_set.count %} +
    + {% for p in vlan.prefix_set.all %} +
  • {{ p }}
  • + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/vlan_edit.html b/netbox/templates/ipam/vlan_edit.html new file mode 100644 index 000000000..f2b4edaa5 --- /dev/null +++ b/netbox/templates/ipam/vlan_edit.html @@ -0,0 +1,49 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Editing VLAN {{ vlan }}{% endblock %} + +{% block content %} +{% if vlan %} +

{{ vlan }}

+{% else %} +

Add a VLAN

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
VLAN
+
+ {% render_field form.site %} + {% render_field form.vid %} + {% render_field form.name %} + {% render_field form.status %} + {% render_field form.role %} +
+
+
+
+ {% if vlan %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/vlan_import.html b/netbox/templates/ipam/vlan_import.html new file mode 100644 index 000000000..538eed44a --- /dev/null +++ b/netbox/templates/ipam/vlan_import.html @@ -0,0 +1,62 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}VLAN Import{% endblock %} + +{% block content %} +

VLAN Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
SiteName of assigned siteLAS2
IDConfigured VLAN ID1400
NameConfigured VLAN nameCameras
StatusCurrent statusActive
RoleFunctional role (optional)Security
+

Example

+
LAS2,1400,Cameras,Active,Security
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/vlan_list.html b/netbox/templates/ipam/vlan_list.html new file mode 100644 index 000000000..a8c0360c7 --- /dev/null +++ b/netbox/templates/ipam/vlan_list.html @@ -0,0 +1,59 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block title %}VLANs{% endblock %} + +{% block content %} +
+ {% if perms.ipam.add_vlan %} + + + Add a VLAN + + + + Import VLANs + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

VLANs

+
+
+ {% include 'ipam/inc/vlan_table.html' with table=vlan_table %} +
+
+
+
+ Search by ID +
+
+
+
+ + + + +
+
+
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/ipam/vrf.html b/netbox/templates/ipam/vrf.html new file mode 100644 index 000000000..5e20ad84e --- /dev/null +++ b/netbox/templates/ipam/vrf.html @@ -0,0 +1,74 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} + +{% block title %}VRF {{ vrf }}{% endblock %} + +{% block content %} +
+ {% if perms.ipam.change_vrf %} + + + Edit this VRF + + {% endif %} + {% if perms.ipam.delete_vrf %} + + + Delete this VRF + + {% endif %} +
+

{{ vrf }}

+
+
+
+
+ Details +
+ + + + + + + + + +
Route Distinguisher{{ vrf.rd }}
Description + {% if vrf.description %} + {{ vrf.description }} + {% else %} + None + {% endif %} +
+
+
+
+
+
+ Prefixes +
+ {% if prefixes %} + + {% for p in prefixes %} + + + + + + + {% endfor %} +
+ {{ p }} + + {% if p.site %} + {{ p.site }} + {% endif %} + {{ p.status }}{{ p.role }}
+ {% else %} +
None
+ {% endif %} +
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/vrf_bulk_delete.html b/netbox/templates/ipam/vrf_bulk_delete.html new file mode 100644 index 000000000..58d4d8899 --- /dev/null +++ b/netbox/templates/ipam/vrf_bulk_delete.html @@ -0,0 +1,15 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete VRFs?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete these VRFs? +

+
    + {% for vrf in selected_objects %} +
  • {{ vrf }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/ipam/vrf_bulk_edit.html b/netbox/templates/ipam/vrf_bulk_edit.html new file mode 100644 index 000000000..0c6d83be6 --- /dev/null +++ b/netbox/templates/ipam/vrf_bulk_edit.html @@ -0,0 +1,14 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}VRF Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for vrf in selected_objects %} + + {{ vrf.name }} + {{ vrf.rd }} + {{ vrf.description }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/ipam/vrf_delete.html b/netbox/templates/ipam/vrf_delete.html new file mode 100644 index 000000000..e62a36916 --- /dev/null +++ b/netbox/templates/ipam/vrf_delete.html @@ -0,0 +1,22 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete VRF {{ vrf }}?{% endblock %} + +{% block message %} +

+ Are you sure you want to delete this VRF? + {% if vrf.prefix_set.count %} + The following prefixes will also be deleted: + {% else %} + (There are no prefixes associated with this VRF.) + {% endif %} +

+ {% if vrf.prefix_set.count %} +
    + {% for p in vrf.prefix_set.all %} +
  • {{ p }}
  • + {% endfor %} +
+ {% endif %} +{% endblock %} diff --git a/netbox/templates/ipam/vrf_edit.html b/netbox/templates/ipam/vrf_edit.html new file mode 100644 index 000000000..4504ed8c1 --- /dev/null +++ b/netbox/templates/ipam/vrf_edit.html @@ -0,0 +1,45 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if vrf %}Editing VRF {{ vrf }}{% else %}Add a VRF{% endif %}{% endblock %} + +{% block content %} +{% if vrf %} +

{{ vrf }}

+{% else %} +

Add a VRF

+{% endif %} +
+ {% csrf_token %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
VRF
+
+ {% render_form form %} +
+
+
+
+ {% if vrf %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/vrf_import.html b/netbox/templates/ipam/vrf_import.html new file mode 100644 index 000000000..b852be6de --- /dev/null +++ b/netbox/templates/ipam/vrf_import.html @@ -0,0 +1,52 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}VRF Import{% endblock %} + +{% block content %} +

VRF Import

+
+
+
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
NameName of VRFCustomer_ABC
RDRoute distinguisher65000:123456
DescriptionShort description (optional)Native VRF for customer ABC
+

Example

+
Customer_ABC,65000:123456,Native VRF for customer ABC
+
+
+{% endblock %} diff --git a/netbox/templates/ipam/vrf_list.html b/netbox/templates/ipam/vrf_list.html new file mode 100644 index 000000000..40da95576 --- /dev/null +++ b/netbox/templates/ipam/vrf_list.html @@ -0,0 +1,58 @@ +{% extends '_base.html' %} +{% load helpers %} +{% load form_helpers %} + +{% block title %}VRFs{% endblock %} + +{% block content %} +
+ {% if perms.ipam.add_vrf %} + + + Add a VRF + + + + Import VRFs + + {% endif %} + {% if export_templates %} +
+ + +
+ {% endif %} +
+

VRFs

+
+
+ {% include 'ipam/inc/vrf_table.html' with table=vrf_table %} +
+
+
+
+ Search +
+
+
+
+ + + + +
+
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/login.html b/netbox/templates/login.html new file mode 100644 index 000000000..13c41d682 --- /dev/null +++ b/netbox/templates/login.html @@ -0,0 +1,32 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+ Log In +
+
+ {% csrf_token %} + {% if 'next' in request.GET %}{% endif %} + {% render_form form %} +
+ +
+
+
+
+{% endblock %} diff --git a/netbox/templates/paginator.html b/netbox/templates/paginator.html new file mode 100644 index 000000000..ea9bcd9dd --- /dev/null +++ b/netbox/templates/paginator.html @@ -0,0 +1,35 @@ +{% load django_tables2 %} + +{# Custom pagination controls to render nicely with Bootstrap CSS. smart_pages requires EnhancedPaginator. #} + +
+
+ {% if table.paginator.num_pages > 1 %} + + {% endif %} +
+
+ Showing {{ table.page.start_index }}-{{ table.page.end_index }} of {{ total }} + {% if total == 1 %} + {{ table.data.verbose_name }} + {% else %} + {{ table.data.verbose_name_plural }} + {% endif %} +
+
\ No newline at end of file diff --git a/netbox/templates/panel_table.html b/netbox/templates/panel_table.html new file mode 100644 index 000000000..0d44d7ea9 --- /dev/null +++ b/netbox/templates/panel_table.html @@ -0,0 +1,25 @@ +{% extends 'django_tables2/table.html' %} +{% load django_tables2 %} +{% load i18n %} + +{# Wraps a table inside a Bootstrap panel and includes custom pagination rendering #} + +{% block table %} +
+ {% if heading %} +
+ {{ heading }} +
+ {% endif %} + {% if table.rows %} + {{ block.super }} + {% else %} +
None
+ {% endif %} +
+{% endblock %} + +{% block pagination %} + {% include 'paginator.html' %} +{% endblock pagination %} + diff --git a/netbox/templates/secrets/inc/private_key_modal.html b/netbox/templates/secrets/inc/private_key_modal.html new file mode 100644 index 000000000..c60823973 --- /dev/null +++ b/netbox/templates/secrets/inc/private_key_modal.html @@ -0,0 +1,25 @@ + diff --git a/netbox/templates/secrets/inc/secret_table.html b/netbox/templates/secrets/inc/secret_table.html new file mode 100644 index 000000000..cf26d0a8d --- /dev/null +++ b/netbox/templates/secrets/inc/secret_table.html @@ -0,0 +1,21 @@ +{% load render_table from django_tables2 %} +{% if perms.secrets.change_secret or perms.secrets.delete_secret %} +
+ {% csrf_token %} + {% render_table table table_template|default:'table.html' %} + {% if perms.secret.change_secret %} + + {% endif %} + {% if perms.secret.delete_secret %} + + {% endif %} +
+{% else %} + {% render_table table table_template|default:'table.html' %} +{% endif %} diff --git a/netbox/templates/secrets/inc/secret_tr.html b/netbox/templates/secrets/inc/secret_tr.html new file mode 100644 index 000000000..cc97a6eb0 --- /dev/null +++ b/netbox/templates/secrets/inc/secret_tr.html @@ -0,0 +1,13 @@ + + {{ secret.role }} + {{ secret.name }} + ******** + + + + + diff --git a/netbox/templates/secrets/secret.html b/netbox/templates/secrets/secret.html new file mode 100644 index 000000000..0b16a1409 --- /dev/null +++ b/netbox/templates/secrets/secret.html @@ -0,0 +1,99 @@ +{% extends '_base.html' %} + +{% block title %}Secret: {{ secret }}{% endblock %} + +{% block content %} +
+
+ +
+
+
+ {% if perms.secrets.change_secret %} + + + Edit this secret + + {% endif %} + {% if perms.secrets.delete_secret %} + + + Delete this secret + + {% endif %} +
+

{{ secret }}

+
+
+
+
+ Secret Attributes +
+ + + + + + + + + + + + + + + + + + + + + +
Parent + {{ secret.parent }} +
Role{{ secret.role }}
Name + {% if secret.name %} + {{ secret.name }} + {% else %} + N/A + {% endif %} +
Created{{ secret.created|date }}
Last Modified{{ secret.last_modified|date:'DATETIME_FORMAT' }}
+
+
+
+
+
+ Secret Data +
+
+
+ {% csrf_token %} +
+
+
Secret
+
********
+
+ + +
+
+
+
+
+
+ +{% include 'secrets/inc/private_key_modal.html' %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/secrets/secret_bulk_delete.html b/netbox/templates/secrets/secret_bulk_delete.html new file mode 100644 index 000000000..feb525424 --- /dev/null +++ b/netbox/templates/secrets/secret_bulk_delete.html @@ -0,0 +1,13 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete Secrets?{% endblock %} + +{% block message %} +

Are you sure you want to delete these secrets?

+
    + {% for secret in selected_objects %} +
  • {{ secret }}
  • + {% endfor %} +
+{% endblock %} diff --git a/netbox/templates/secrets/secret_bulk_edit.html b/netbox/templates/secrets/secret_bulk_edit.html new file mode 100644 index 000000000..5bb5c2ef1 --- /dev/null +++ b/netbox/templates/secrets/secret_bulk_edit.html @@ -0,0 +1,15 @@ +{% extends 'utilities/bulk_edit_form.html' %} +{% load form_helpers %} + +{% block title %}Secret Bulk Edit{% endblock %} + +{% block select_objects_table %} + {% for secret in selected_objects %} + + {{ secret }} + {{ secret.parent }} + {{ secret.role }} + {{ secret.name }} + + {% endfor %} +{% endblock %} diff --git a/netbox/templates/secrets/secret_delete.html b/netbox/templates/secrets/secret_delete.html new file mode 100644 index 000000000..4e6068e6a --- /dev/null +++ b/netbox/templates/secrets/secret_delete.html @@ -0,0 +1,8 @@ +{% extends 'utilities/confirmation_form.html' %} +{% load form_helpers %} + +{% block title %}Delete secret {{ secret }}?{% endblock %} + +{% block message %} +

Are you sure you want to delete secret {{ secret }}?

+{% endblock %} diff --git a/netbox/templates/secrets/secret_edit.html b/netbox/templates/secrets/secret_edit.html new file mode 100644 index 000000000..8a719d804 --- /dev/null +++ b/netbox/templates/secrets/secret_edit.html @@ -0,0 +1,83 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}{% if secret.pk %}Editing Secret: {{ secret }}{% else %}Add a Secret{% endif %}{% endblock %} + +{% block content %} +{% if secret.pk %} +

Editing Secret: {{ secret }}

+{% else %} +

Add a Secret

+{% endif %} +
+ {% csrf_token %} + {{ form.private_key }} +
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
+
+
+
+
Secret Attributes
+
+
+ +
+

{{ secret.parent }}

+
+
+ {% render_field form.role %} + {% render_field form.name %} + {% render_field form.userkeys %} +
+
+
+
+
+
Secret Data
+
+ {% if secret.pk %} +
+ +
+

********

+
+
+ {% endif %} + {% render_field form.plaintext %} + {% render_field form.plaintext2 %} +
+
+
+
+
+
+
+ {% if secret.pk %} + + Cancel + {% else %} + + + Cancel + {% endif %} +
+
+
+
+ +{% include 'secrets/inc/private_key_modal.html' %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/secrets/secret_import.html b/netbox/templates/secrets/secret_import.html new file mode 100644 index 000000000..694a0f613 --- /dev/null +++ b/netbox/templates/secrets/secret_import.html @@ -0,0 +1,71 @@ +{% extends '_base.html' %} +{% load render_table from django_tables2 %} +{% load form_helpers %} + +{% block title %}Secret Import{% endblock %} + +{% block content %} +

Secret Import

+
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+ {% csrf_token %} + {% render_form form %} +
+ + Cancel +
+
+
+
+

CSV Format

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldDescriptionExample
ParentName of the parent objectedge-router1
RoleFunctional roleLogin Credentials
Name (optional)Username or other labelroot
SecretSecret dataMyP@ssw0rd!
+

Example

+
edge-router1,Login Credentials,root,MyP@ssw0rd!
+
+
+ +{% include 'secrets/inc/private_key_modal.html' %} +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/secrets/secret_list.html b/netbox/templates/secrets/secret_list.html new file mode 100644 index 000000000..8dfbb9e2d --- /dev/null +++ b/netbox/templates/secrets/secret_list.html @@ -0,0 +1,24 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}Secrets{% endblock %} + +{% block content %} +
+ {% if perms.secrets.add_secret %} + + + Import secrets + + {% endif %} +
+

Secrets

+
+
+ {% include 'secrets/inc/secret_table.html' with table=secret_table %} +
+
+ {% include 'inc/filter_panel.html' %} +
+
+{% endblock %} diff --git a/netbox/templates/table.html b/netbox/templates/table.html new file mode 100644 index 000000000..8782f0796 --- /dev/null +++ b/netbox/templates/table.html @@ -0,0 +1,8 @@ +{% extends 'django_tables2/table.html' %} +{% load django_tables2 %} + +{# Extends the stock django_tables2 template to provide custom formatting of the pagination controls #} + +{% block pagination %} + {% include 'paginator.html' %} +{% endblock pagination %} diff --git a/netbox/templates/users/change_password.html b/netbox/templates/users/change_password.html new file mode 100644 index 000000000..c8670faff --- /dev/null +++ b/netbox/templates/users/change_password.html @@ -0,0 +1,46 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}Change Password{% endblock %} + +{% block content %} +
+
+

Change Password

+
+
+
+
+ {% include 'users/inc/profile_nav.html' with active_tab="change_password" %} +
+
+
+ {% csrf_token %} + {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Password
+
+ {% render_field form.old_password %} + {% render_field form.new_password1 %} + {% render_field form.new_password2 %} +
+
+
+
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/users/inc/profile_nav.html b/netbox/templates/users/inc/profile_nav.html new file mode 100644 index 000000000..f21db3e1d --- /dev/null +++ b/netbox/templates/users/inc/profile_nav.html @@ -0,0 +1,5 @@ + diff --git a/netbox/templates/users/profile.html b/netbox/templates/users/profile.html new file mode 100644 index 000000000..5d9981dc0 --- /dev/null +++ b/netbox/templates/users/profile.html @@ -0,0 +1,31 @@ +{% extends '_base.html' %} +{% load helpers %} + +{% block title %}User Profile{% endblock %} + +{% block content %} +
+
+

User Profile

+
+
+
+
+ {% include 'users/inc/profile_nav.html' with active_tab="profile" %} +
+
+ User login +
{{ request.user.username }}
+ Full name +
{{ request.user.first_name }} {{ request.user.last_name }}
+ Email +
{{ request.user.email }}
+ Registered +
{{ request.user.date_joined|date }}
+ Groups +
{{ request.user.groups.all|join:', ' }}
+ Admin access +
{{ request.user.is_staff|yesno|capfirst }}
+
+
+{% endblock %} diff --git a/netbox/templates/users/userkey.html b/netbox/templates/users/userkey.html new file mode 100644 index 000000000..9728ef93f --- /dev/null +++ b/netbox/templates/users/userkey.html @@ -0,0 +1,45 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}User Key{% endblock %} + +{% block content %} +
+
+

User Key

+
+
+
+
+ {% include 'users/inc/profile_nav.html' with active_tab="userkey" %} +
+
+ {% if userkey %} +

+ Your user key is: + {% if userkey.is_active %} + Active + {% else %} + Inactive + {% endif %} +

+

Your public key is below.

+
{{ userkey.public_key }}
+ + {% else %} +

You don't have a user key on file.

+

+ + + Create a User Key + +

+ {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/users/userkey_edit.html b/netbox/templates/users/userkey_edit.html new file mode 100644 index 000000000..d0440e250 --- /dev/null +++ b/netbox/templates/users/userkey_edit.html @@ -0,0 +1,72 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block title %}User Key{% endblock %} + +{% block content %} +
+
+

User Key

+
+
+
+
+ {% include 'users/inc/profile_nav.html' with active_tab="userkey" %} +
+
+ {% if userkey.is_active %} + + {% endif %} +
+ {% csrf_token %} +
+ {% render_field form.public_key %} +
+
+
+
+ +
+
+ + Cancel +
+
+
+
+
+
+ + +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/utilities/bulk_edit_form.html b/netbox/templates/utilities/bulk_edit_form.html new file mode 100644 index 000000000..841206480 --- /dev/null +++ b/netbox/templates/utilities/bulk_edit_form.html @@ -0,0 +1,44 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +

{% block title %}{% endblock %}

+
+ {% csrf_token %} + {% for hidden in form.hidden_fields %} + {{ hidden }} + {% endfor %} +
+
+
+
{% block selected_objects_title %}Selected For Editing{% endblock %}
+ + {% block select_objects_table %}{% endblock %} +
+
+
+
+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
{% block form_title %}Attributes{% endblock %}
+
+ {% render_form form %} +
+
+
+
+ + Cancel +
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/utilities/confirmation_form.html b/netbox/templates/utilities/confirmation_form.html new file mode 100644 index 000000000..122f319b6 --- /dev/null +++ b/netbox/templates/utilities/confirmation_form.html @@ -0,0 +1,33 @@ +{% extends '_base.html' %} +{% load form_helpers %} + +{% block content %} +
+
+
+ {% csrf_token %} +
+
{% block title %}{% endblock %}
+
+ {% block message %}Are you sure?{% endblock %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+ +
+
+
+ + Cancel +
+
+
+
+
+
+{% endblock %} diff --git a/netbox/templates/utilities/render_field.html b/netbox/templates/utilities/render_field.html new file mode 100644 index 000000000..6a7886cce --- /dev/null +++ b/netbox/templates/utilities/render_field.html @@ -0,0 +1,52 @@ +{% load form_helpers %} + +
+ {% if field|widget_type == 'checkboxinput' %} +
+
+ +
+
+ {% elif field|widget_type == 'radioselect' %} +
+
+ +
+
+ {% elif field|widget_type == 'textarea' %} +
+ {{ field }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% else %} + +
+ {{ field }} + {% if field.help_text %} + {{ field.help_text|safe }} + {% endif %} + {% if field.errors %} +
    + {% for error in field.errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ {% endif %} +
diff --git a/netbox/templates/utilities/render_form.html b/netbox/templates/utilities/render_form.html new file mode 100644 index 000000000..928b4c097 --- /dev/null +++ b/netbox/templates/utilities/render_form.html @@ -0,0 +1,8 @@ +{% load form_helpers %} + +{% for field in form.hidden_fields %} + {{ field }} +{% endfor %} +{% for field in form.visible_fields %} + {% render_field field %} +{% endfor %} diff --git a/netbox/users/__init__.py b/netbox/users/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/admin.py b/netbox/users/admin.py new file mode 100644 index 000000000..8c38f3f3d --- /dev/null +++ b/netbox/users/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/netbox/users/forms.py b/netbox/users/forms.py new file mode 100644 index 000000000..c07175347 --- /dev/null +++ b/netbox/users/forms.py @@ -0,0 +1,16 @@ +from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm as DjangoPasswordChangeForm + +from utilities.forms import BootstrapMixin + + +class LoginForm(AuthenticationForm, BootstrapMixin): + + def __init__(self, *args, **kwargs): + super(LoginForm, self).__init__(*args, **kwargs) + + self.fields['username'].widget.attrs['placeholder'] = '' + self.fields['password'].widget.attrs['placeholder'] = '' + + +class PasswordChangeForm(DjangoPasswordChangeForm, BootstrapMixin): + pass diff --git a/netbox/users/migrations/0001_initial.py b/netbox/users/migrations/0001_initial.py new file mode 100644 index 000000000..53aa32cae --- /dev/null +++ b/netbox/users/migrations/0001_initial.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-29 18:49 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + ] diff --git a/netbox/users/migrations/__init__.py b/netbox/users/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/users/models.py b/netbox/users/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/netbox/users/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/netbox/users/tests.py b/netbox/users/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/netbox/users/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/netbox/users/urls.py b/netbox/users/urls.py new file mode 100644 index 000000000..0f86b2107 --- /dev/null +++ b/netbox/users/urls.py @@ -0,0 +1,10 @@ +from django.conf.urls import url + +from . import views + +urlpatterns = [ + url(r'^profile/$', views.profile, name='profile'), + url(r'^profile/password/$', views.change_password, name='change_password'), + url(r'^profile/user-key/$', views.userkey, name='userkey'), + url(r'^profile/user-key/edit/$', views.userkey_edit, name='userkey_edit'), +] diff --git a/netbox/users/views.py b/netbox/users/views.py new file mode 100644 index 000000000..d2274071c --- /dev/null +++ b/netbox/users/views.py @@ -0,0 +1,117 @@ +from django.conf import settings +from django.contrib import messages +from django.contrib.auth import login as auth_login, logout as auth_logout, update_session_auth_hash +from django.contrib.auth.decorators import login_required +from django.core.urlresolvers import reverse +from django.http import HttpResponseRedirect +from django.shortcuts import redirect, render, resolve_url +from django.utils.http import is_safe_url + +from secrets.forms import UserKeyForm +from secrets.models import UserKey +from .forms import LoginForm, PasswordChangeForm + + +# +# Login/logout +# + +def login(request): + + if request.method == 'POST': + form = LoginForm(request, data=request.POST) + if form.is_valid(): + + # Determine where to direct user after successful login + redirect_to = request.POST.get('next', '') + if not is_safe_url(url=redirect_to, host=request.get_host()): + redirect_to = resolve_url(settings.LOGIN_REDIRECT_URL) + + # Authenticate user + auth_login(request, form.get_user()) + messages.info(request, "Logged in as {0}.".format(request.user)) + + return HttpResponseRedirect(redirect_to) + + else: + form = LoginForm() + + return render(request, 'login.html', { + 'form': form, + }) + + +def logout(request): + + auth_logout(request) + messages.info(request, "You have logged out.") + return HttpResponseRedirect(reverse('home')) + + +# +# User profiles +# + +@login_required() +def profile(request): + + return render(request, 'users/profile.html', { + }) + + +@login_required() +def change_password(request): + + if request.method == 'POST': + form = PasswordChangeForm(user=request.user, data=request.POST) + if form.is_valid(): + form.save() + update_session_auth_hash(request, form.user) + messages.success(request, "Your password has been changed successfully.") + return redirect('users:profile') + + else: + form = PasswordChangeForm(user=request.user) + + return render(request, 'users/change_password.html', { + 'form': form, + }) + + +@login_required() +def userkey(request): + + try: + userkey = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + userkey = None + + return render(request, 'users/userkey.html', { + 'userkey': userkey, + }) + + +@login_required() +def userkey_edit(request): + + try: + userkey = UserKey.objects.get(user=request.user) + except UserKey.DoesNotExist: + userkey = UserKey(user=request.user) + + if request.method == 'POST': + form = UserKeyForm(data=request.POST, instance=userkey) + if form.is_valid(): + uk = form.save(commit=False) + uk.user = request.user + uk.save() + messages.success(request, "Your user key has been saved.") + return redirect('users:userkey') + + else: + form = UserKeyForm(instance=userkey) + + return render(request, 'users/userkey_edit.html', { + 'userkey': userkey, + 'form': form, + }) diff --git a/netbox/utilities/__init__.py b/netbox/utilities/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/api.py b/netbox/utilities/api.py new file mode 100644 index 000000000..ff35fd293 --- /dev/null +++ b/netbox/utilities/api.py @@ -0,0 +1,6 @@ +from rest_framework.exceptions import APIException + + +class ServiceUnavailable(APIException): + status_code = 503 + default_detail = "Service temporarily unavailable, please try again later." diff --git a/netbox/utilities/context_processors.py b/netbox/utilities/context_processors.py new file mode 100644 index 000000000..6b1bb0af5 --- /dev/null +++ b/netbox/utilities/context_processors.py @@ -0,0 +1,7 @@ +from django.conf import settings as django_settings + + +def settings(request): + return { + 'settings': django_settings, + } diff --git a/netbox/utilities/error_handlers.py b/netbox/utilities/error_handlers.py new file mode 100644 index 000000000..d8bbd0ff9 --- /dev/null +++ b/netbox/utilities/error_handlers.py @@ -0,0 +1,29 @@ +from django.contrib import messages + + +def handle_protectederror(obj, request, e): + """ + Generate a user-friendly error message in response to a ProtectedError exception. + """ + dependent_objects = e[1] + try: + dep_class = dependent_objects[0]._meta.verbose_name_plural + except IndexError: + raise e + + # Handle multiple triggering objects + if type(obj) in (list, tuple): + messages.error(request, "Unable to delete the requested {}. The following dependent {} were found: {}".format( + obj[0]._meta.verbose_name_plural, + dep_class, + ', '.join([str(o) for o in dependent_objects]) + )) + + # Handle a single triggering object + else: + messages.error(request, "Unable to delete {} {}. The following dependent {} were found: {}".format( + obj._meta.verbose_name, + obj, + dep_class, + ', '.join([str(o) for o in dependent_objects]) + )) diff --git a/netbox/utilities/fields.py b/netbox/utilities/fields.py new file mode 100644 index 000000000..5f93612d3 --- /dev/null +++ b/netbox/utilities/fields.py @@ -0,0 +1,14 @@ +from django.db import models + + +class NullableCharField(models.CharField): + description = "Stores empty values as NULL rather than ''" + #__metaclass__ = models.SubfieldBase + + def to_python(self, value): + if isinstance(value, models.CharField): + return value + return value or '' + + def get_prep_value(self, value): + return value or None diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py new file mode 100644 index 000000000..a796392f9 --- /dev/null +++ b/netbox/utilities/forms.py @@ -0,0 +1,248 @@ +import re + +from django import forms +from django.core.urlresolvers import reverse_lazy +from django.utils.encoding import force_text +from django.utils.html import format_html +from django.utils.safestring import mark_safe + + +EXPANSION_PATTERN = '\[(\d+-\d+)\]' + + +def expand_pattern(string): + """ + Expand a numeric pattern into a list of strings. Examples: + 'ge-0/0/[0-3]' => ['ge-0/0/0', 'ge-0/0/1', 'ge-0/0/2', 'ge-0/0/3'] + 'xe-0/[0-3]/[0-7]' => ['xe-0/0/0', 'xe-0/0/1', 'xe-0/0/2', ... 'xe-0/3/5', 'xe-0/3/6', 'xe-0/3/7'] + """ + lead, pattern, remnant = re.split(EXPANSION_PATTERN, string, maxsplit=1) + x, y = pattern.split('-') + for i in range(int(x), int(y) + 1): + if remnant: + for string in expand_pattern(remnant): + yield "{}{}{}".format(lead, i, string) + else: + yield "{}{}".format(lead, i) + + +# +# Widgets +# + +class SmallTextarea(forms.Textarea): + pass + + +class SelectWithDisabled(forms.Select): + """ + Modified the stock Select widget to accept choices using a dict() for a label. The dict for each option must include + 'label' (string) and 'disabled' (boolean). + """ + + def render_option(self, selected_choices, option_value, option_label): + + # Determine if option has been selected + option_value = force_text(option_value) + if option_value in selected_choices: + selected_html = mark_safe(' selected="selected"') + if not self.allow_multiple_selected: + # Only allow for a single selection. + selected_choices.remove(option_value) + else: + selected_html = '' + + # Determine if option has been disabled + option_disabled = False + exempt_value = force_text(self.attrs.get('exempt', None)) + if isinstance(option_label, dict): + option_disabled = option_label['disabled'] if option_value != exempt_value else False + option_label = option_label['label'] + disabled_html = ' disabled="disabled"' if option_disabled else '' + + return format_html('', + option_value, + selected_html, + disabled_html, + force_text(option_label)) + + +class APISelect(SelectWithDisabled): + """ + A select widget populated via an API call + + :param api_url: API URL + :param display_field: (Optional) Field to display for child in selection list. Defaults to `name`. + :param disabled_indicator: (Optional) Mark option as disabled if this field equates true. + """ + + def __init__(self, api_url, display_field=None, disabled_indicator=None, *args, **kwargs): + + super(APISelect, self).__init__(*args, **kwargs) + + self.attrs['class'] = 'api-select' + self.attrs['api-url'] = api_url + if display_field: + self.attrs['display-field'] = display_field + if disabled_indicator: + self.attrs['disabled-indicator'] = disabled_indicator + + +class Livesearch(forms.TextInput): + """ + A text widget that carries a few extra bits of data for use in AJAX-powered autocomplete search + + :param query_key: The name of the parameter to query against + :param query_url: The name of the API URL to query + :param field_to_update: The name of the "real" form field whose value is being set + :param obj_label: The field to use as the option label (optional) + """ + + def __init__(self, query_key, query_url, field_to_update, obj_label=None, *args, **kwargs): + + super(Livesearch, self).__init__(*args, **kwargs) + + self.attrs = { + 'data-key': query_key, + 'data-source': reverse_lazy(query_url), + 'data-field': field_to_update, + } + + if obj_label: + self.attrs['data-label'] = obj_label + + +# +# Form fields +# + +class CSVDataField(forms.CharField): + """ + A field for comma-separated values (CSV) + """ + csv_form = None + + def __init__(self, csv_form, *args, **kwargs): + self.csv_form = csv_form + self.columns = self.csv_form().fields.keys() + self.widget = forms.Textarea + super(CSVDataField, self).__init__(*args, **kwargs) + self.strip = False + if not self.label: + self.label = 'CSV Data' + if not self.help_text: + self.help_text = 'Enter one line per record in CSV format.' + + def to_python(self, value): + # Return a list of dictionaries, each representing an individual record + records = [] + for i, row in enumerate(value.split('\n'), start=1): + if row.strip(): + values = row.strip().split(',') + if len(values) < len(self.columns): + raise forms.ValidationError("Line {}: Field(s) missing (found {}; expected {})" + .format(i, len(values), len(self.columns))) + elif len(values) > len(self.columns): + raise forms.ValidationError("Line {}: Too many fields (found {}; expected {})" + .format(i, len(values), len(self.columns))) + record = dict(zip(self.columns, values)) + records.append(record) + return records + + +class ExpandableNameField(forms.CharField): + """ + A field which allows for numeric range expansion + Example: 'Gi0/[1-3]' => ['Gi0/1', 'Gi0/2', 'Gi0/3'] + """ + def __init__(self, *args, **kwargs): + super(ExpandableNameField, self).__init__(*args, **kwargs) + if not self.help_text: + self.help_text = 'Numeric ranges are supported for bulk creation.' + + def to_python(self, value): + if re.search(EXPANSION_PATTERN, value): + return list(expand_pattern(value)) + return [value] + + +class CommentField(forms.CharField): + """ + A textarea with support for GitHub-Flavored Markdown. Exists mostly just to add a standard help_text. + """ + widget = forms.Textarea + # TODO: Port GFM syntax cheat sheet to internal documentation + default_helptext = ' '\ + ''\ + 'GitHub-Flavored Markdown syntax is supported' + + def __init__(self, *args, **kwargs): + required = kwargs.pop('required', False) + help_text = kwargs.pop('help_text', self.default_helptext) + super(CommentField, self).__init__(required=required, help_text=help_text, *args, **kwargs) + + +class FlexibleModelChoiceField(forms.ModelChoiceField): + """ + Allow a model to be reference by either '{ID}' or the field specified by `to_field_name`. + """ + def to_python(self, value): + if value in self.empty_values: + return None + try: + if not self.to_field_name: + key = 'pk' + elif re.match('^\{\d+\}$', value): + key = 'pk' + value = value.strip('{}') + else: + key = self.to_field_name + value = self.queryset.get(**{key: value}) + except (ValueError, TypeError, self.queryset.model.DoesNotExist): + raise forms.ValidationError(self.error_messages['invalid_choice'], code='invalid_choice') + return value + + +# +# Forms +# + +class BootstrapMixin(forms.BaseForm): + + def __init__(self, *args, **kwargs): + super(BootstrapMixin, self).__init__(*args, **kwargs) + for field_name, field in self.fields.items(): + if type(field.widget) not in [type(forms.CheckboxInput()), type(forms.RadioSelect())]: + try: + field.widget.attrs['class'] += ' form-control' + except KeyError: + field.widget.attrs['class'] = 'form-control' + if field.required: + field.widget.attrs['required'] = 'required' + field.widget.attrs['placeholder'] = field.label + + +class ConfirmationForm(forms.Form, BootstrapMixin): + confirm = forms.BooleanField(required=True) + + +class BulkImportForm(forms.Form): + + def clean(self): + records = self.cleaned_data.get('csv') + if not records: + return + + obj_list = [] + + for i, record in enumerate(records, start=1): + obj_form = self.fields['csv'].csv_form(data=record) + if obj_form.is_valid(): + obj = obj_form.save(commit=False) + obj_list.append(obj) + else: + for field, errors in obj_form.errors.items(): + for e in errors: + self.add_error('csv', "Record {} ({}): {}".format(i, field, e)) + + self.cleaned_data['csv'] = obj_list diff --git a/netbox/utilities/middleware.py b/netbox/utilities/middleware.py new file mode 100644 index 000000000..563e4a0fe --- /dev/null +++ b/netbox/utilities/middleware.py @@ -0,0 +1,15 @@ +from django.http import HttpResponseRedirect +from django.conf import settings + + +LOGIN_REQUIRED = getattr(settings, 'LOGIN_REQUIRED', False) + + +class LoginRequiredMiddleware: + """ + If LOGIN_REQUIRED is True, redirect all non-authenticated users to the login page. + """ + def process_request(self, request): + if LOGIN_REQUIRED and not request.user.is_authenticated(): + if request.path_info != settings.LOGIN_URL: + return HttpResponseRedirect(settings.LOGIN_URL) diff --git a/netbox/utilities/migrations/0001_initial.py b/netbox/utilities/migrations/0001_initial.py new file mode 100644 index 000000000..2e65b3ffa --- /dev/null +++ b/netbox/utilities/migrations/0001_initial.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.1 on 2016-02-29 18:50 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ] + + operations = [ + ] diff --git a/netbox/utilities/migrations/__init__.py b/netbox/utilities/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/models.py b/netbox/utilities/models.py new file mode 100644 index 000000000..71a836239 --- /dev/null +++ b/netbox/utilities/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py new file mode 100644 index 000000000..bcd08d74f --- /dev/null +++ b/netbox/utilities/paginator.py @@ -0,0 +1,30 @@ +from django.core.paginator import Paginator, Page + + +class EnhancedPaginator(Paginator): + + def _get_page(self, *args, **kwargs): + return EnhancedPage(*args, **kwargs) + + +class EnhancedPage(Page): + + def smart_pages(self): + """ + Instead of every page, return only first, last, and nearby pages (taken from + https://www.technovelty.org/web/skipping-pages-with-djangocorepaginator.html). + """ + n = self.number + last_page = self.paginator.num_pages + + # Determine the page numbers to display + pages_wanted = [1, 2, n-2, n-1, n, n+1, n+2, last_page-1, last_page] + pages_to_show = set(self.paginator.page_range).intersection(pages_wanted) + pages_to_show = sorted(pages_to_show) + + # Insert skip markers + skip_pages = [x[1] for x in zip(pages_to_show[:-1], pages_to_show[1:]) if (x[1] - x[0] != 1)] + for i in skip_pages: + pages_to_show.insert(pages_to_show.index(i), False) + + return pages_to_show diff --git a/netbox/utilities/templatetags/__init__.py b/netbox/utilities/templatetags/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/netbox/utilities/templatetags/form_helpers.py b/netbox/utilities/templatetags/form_helpers.py new file mode 100644 index 000000000..e6e74fdf3 --- /dev/null +++ b/netbox/utilities/templatetags/form_helpers.py @@ -0,0 +1,35 @@ +from django import template + + +register = template.Library() + + +@register.inclusion_tag('utilities/render_field.html') +def render_field(field): + """ + Render a single form field from template + """ + return { + 'field': field, + } + + +@register.inclusion_tag('utilities/render_form.html') +def render_form(form): + """ + Render an entire form from template + """ + return { + 'form': form, + } + + +@register.filter(name='widget_type') +def widget_type(field): + """ + Return the widget type + """ + try: + return field.field.widget.__class__.__name__.lower() + except AttributeError: + return None diff --git a/netbox/utilities/templatetags/helpers.py b/netbox/utilities/templatetags/helpers.py new file mode 100644 index 000000000..f7b785284 --- /dev/null +++ b/netbox/utilities/templatetags/helpers.py @@ -0,0 +1,71 @@ +from markdown import markdown + +from django import template +from django.utils.safestring import mark_safe + + +register = template.Library() + + +# +# Filters +# + +@register.filter(name='oneline') +def oneline(value): + """ + Replace each line break with a single space + """ + return value.replace('\n', ' ') + + +@register.filter(name='getlist') +def getlist(value, arg): + """ + Return all values of a QueryDict key + """ + return value.getlist(arg) + + +@register.filter(name='gfm', is_safe=True) +def gfm(value): + """ + Render text as GitHub-Flavored Markdown + """ + html = markdown(value, extensions=['mdx_gfm']) + return mark_safe(html) + + +# +# Tags +# + +@register.simple_tag(name='querystring_toggle') +def querystring_toggle(request, multi=True, page_key='page', **kwargs): + """ + Add or remove a parameter in the HTTP GET query string + """ + new_querydict = request.GET.copy() + + # Remove page number from querystring + try: + new_querydict.pop(page_key) + except KeyError: + pass + + # Add/toggle parameters + for k, v in kwargs.items(): + values = new_querydict.getlist(k) + if k in new_querydict and v in values: + values.remove(v) + new_querydict.setlist(k, values) + elif not multi: + new_querydict[k] = v + else: + new_querydict.update({k: v}) + + querystring = new_querydict.urlencode() + if querystring: + return '?' + querystring + else: + return '' diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py new file mode 100644 index 000000000..b39d99417 --- /dev/null +++ b/netbox/utilities/views.py @@ -0,0 +1,178 @@ +from django.conf import settings +from django.contrib import messages +from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.db import transaction, IntegrityError +from django.db.models import ProtectedError +from django.shortcuts import get_object_or_404, redirect, render +from django.utils.decorators import method_decorator +from django.views.generic import View + +from django_tables2 import RequestConfig + +from .error_handlers import handle_protectederror +from .paginator import EnhancedPaginator +from extras.models import ExportTemplate + + +class ObjectListView(View): + queryset = None + filter = None + filter_form = None + table = None + template_name = None + + def get(self, request, *args, **kwargs): + + object_ct = ContentType.objects.get_for_model(self.queryset.model) + + if self.filter: + self.queryset = self.filter(request.GET, self.queryset).qs + + # Export + if request.GET.get('export'): + et = get_object_or_404(ExportTemplate, content_type=object_ct, name=request.GET.get('export')) + response = et.to_response(context_dict={'queryset': self.queryset}, + filename='netbox_r{}'.format(self.queryset.model._meta.verbose_name_plural)) + return response + + table = self.table(self.queryset) + RequestConfig(request, paginate={'per_page': settings.PAGINATE_COUNT, 'klass': EnhancedPaginator})\ + .configure(table) + + export_templates = ExportTemplate.objects.filter(content_type=object_ct) + + return render(request, self.template_name, { + 'table': table, + 'filter_form': self.filter_form(request.GET, label_suffix='') if self.filter_form else None, + 'export_templates': export_templates, + }) + + +class BulkImportView(View): + form = None + table = None + template_name = None + obj_list_url = None + + def get(self, request, *args, **kwargs): + + return render(request, self.template_name, { + 'form': self.form(), + 'obj_list_url': self.obj_list_url, + }) + + def post(self, request, *args, **kwargs): + + form = self.form(request.POST) + if form.is_valid(): + new_objs = [] + try: + with transaction.atomic(): + for obj in form.cleaned_data['csv']: + self.save_obj(obj) + new_objs.append(obj) + + obj_table = self.table(new_objs) + messages.success(request, "Imported {} objects".format(len(new_objs))) + + return render(request, "import_success.html", { + 'table': obj_table, + }) + + except IntegrityError as e: + form.add_error('csv', "Record {}: {}".format(len(new_objs) + 1, e.__cause__)) + + return render(request, self.template_name, { + 'form': form, + 'obj_list_url': self.obj_list_url, + }) + + def save_obj(self, obj): + obj.save() + + +class BulkEditView(View): + cls = None + form = None + template_name = None + redirect_url = None + + def get(self, request, *args, **kwargs): + return redirect(self.redirect_url) + + def post(self, request, *args, **kwargs): + + if '_apply' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + pk_list = [obj.pk for obj in form.cleaned_data['pk']] + self.update_objects(pk_list, form) + if not form.errors: + return redirect(self.redirect_url) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + + selected_objects = self.cls.objects.filter(pk__in=request.POST.getlist('pk')) + if not selected_objects: + messages.warning(request, "No {} were selected.".format(self.cls._meta.verbose_name_plural)) + return redirect(self.redirect_url) + + return render(request, self.template_name, { + 'form': form, + 'selected_objects': selected_objects, + 'cancel_url': reverse(self.redirect_url), + }) + + def update_objects(self, obj_list, form): + """ + This method provides the update logic (must be overridden by subclasses). + """ + raise NotImplementedError() + + +class BulkDeleteView(View): + cls = None + form = None + template_name = None + redirect_url = None + + @method_decorator(staff_member_required) + def dispatch(self, *args, **kwargs): + return super(BulkDeleteView, self).dispatch(*args, **kwargs) + + def get(self, request, *args, **kwargs): + return redirect(self.redirect_url) + + def post(self, request, *args, **kwargs): + if '_confirm' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + # Delete objects + objects_to_delete = self.cls.objects.filter(pk__in=[v.id for v in form.cleaned_data['pk']]) + try: + deleted_count = objects_to_delete.count() + objects_to_delete.delete() + except ProtectedError, e: + handle_protectederror(list(objects_to_delete), request, e) + return redirect(self.redirect_url) + + messages.success(request, "Deleted {} {}".format(deleted_count, self.cls._meta.verbose_name_plural)) + return redirect(self.redirect_url) + + else: + form = self.form(initial={'pk': request.POST.getlist('pk')}) + + selected_objects = self.cls.objects.filter(pk__in=form.initial.get('pk')) + if not selected_objects: + messages.warning(request, "No {} were selected for deletion.".format(self.cls._meta.verbose_name_plural)) + return redirect(self.redirect_url) + + return render(request, self.template_name, { + 'form': form, + 'selected_objects': selected_objects, + 'cancel_url': reverse(self.redirect_url), + }) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..0c9ff5391 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,25 @@ +Django==1.9.1 +django-cors-headers==1.1.0 +django-debug-toolbar==1.4 +django-extensions==1.6.1 +django-filter==0.12.0 +django-rest-swagger==0.3.4 +django-tables2==1.0.7 +djangorestframework==3.3.2 +ecdsa==0.13 +Exscript==2.1.479 +lxml==3.5.0 +Markdown==2.6.5 +MarkupSafe==0.23 +ncclient==0.4.3 +netaddr==0.7.15 +paramiko==1.15.2 +psycopg2==2.6.1 +py-gfm==0.1.1 +pycrypto==2.6.1 +pydot==1.0.2 +pyparsing==1.5.7 +PyYAML==3.11 +six==1.10.0 +sqlparse==0.1.18 +xmltodict==0.9.2