mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-01 14:43:38 +01:00
Compare commits
132 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cc4992fad | ||
|
|
6518d87200 | ||
|
|
8497965cf7 | ||
|
|
0b0ab9277c | ||
|
|
75c62ff729 | ||
|
|
aef8c5fbb5 | ||
|
|
cfa4f5677b | ||
|
|
8131feae8a | ||
|
|
1fc3c6d9d2 | ||
|
|
53a5bc2221 | ||
|
|
d850aa0773 | ||
|
|
9baebfa241 | ||
|
|
10847e2956 | ||
|
|
9b0258fef4 | ||
|
|
5b89cdc868 | ||
|
|
5a8cedd63f | ||
|
|
3feba2997f | ||
|
|
fce419526d | ||
|
|
1b12185a39 | ||
|
|
2e895c734e | ||
|
|
11a9dc57fc | ||
|
|
badd92a50e | ||
|
|
b2faf8044d | ||
|
|
3105e9545a | ||
|
|
42c71984f9 | ||
|
|
db359719a9 | ||
|
|
7bceeb714b | ||
|
|
35b8fc6e83 | ||
|
|
1bb596fc7e | ||
|
|
7bcebd5b0f | ||
|
|
a8b6902829 | ||
|
|
71e6dc8275 | ||
|
|
564640213e | ||
|
|
b04f262642 | ||
|
|
b802127801 | ||
|
|
f23dc2d405 | ||
|
|
7c8612aadd | ||
|
|
d347b97f20 | ||
|
|
46d0af6cef | ||
|
|
ee8fd701ae | ||
|
|
9379324b07 | ||
|
|
55cdbd57cc | ||
|
|
76df55dfc0 | ||
|
|
49a949aa97 | ||
|
|
288bf477ce | ||
|
|
27f3816fc6 | ||
|
|
18a4232783 | ||
|
|
15ed575207 | ||
|
|
eae4502708 | ||
|
|
78ebf04be0 | ||
|
|
49a596073e | ||
|
|
95783cc128 | ||
|
|
8d9d3a9e7d | ||
|
|
ea0de4b01d | ||
|
|
72aaf76cf4 | ||
|
|
78e282d406 | ||
|
|
0c214932ba | ||
|
|
a1eb4dc807 | ||
|
|
e92f13977c | ||
|
|
5db283700f | ||
|
|
6e79e5608e | ||
|
|
8355270a1a | ||
|
|
1c38d63c50 | ||
|
|
4f6944424b | ||
|
|
7ab916b527 | ||
|
|
f25649955e | ||
|
|
04d6a4a371 | ||
|
|
a8140d1f70 | ||
|
|
d1af15037c | ||
|
|
cca76550d6 | ||
|
|
2ff3d0d5a2 | ||
|
|
ffae2c5f18 | ||
|
|
e300fad340 | ||
|
|
a038e8bba4 | ||
|
|
33e825e91e | ||
|
|
c5e74635dd | ||
|
|
61fe0e81cd | ||
|
|
dd0489c1c5 | ||
|
|
ab5a763d93 | ||
|
|
fd7d8cbf56 | ||
|
|
9ff505d11b | ||
|
|
3ed346be86 | ||
|
|
0ed82af99a | ||
|
|
b814123ede | ||
|
|
a3d40e3521 | ||
|
|
4abfa6231c | ||
|
|
5bf4234ad3 | ||
|
|
7640740113 | ||
|
|
82300990ec | ||
|
|
ec5ed17860 | ||
|
|
8f6b71df46 | ||
|
|
e8e3e9b0be | ||
|
|
28ca815c88 | ||
|
|
54dfa6cb7f | ||
|
|
7c667f3485 | ||
|
|
c585175214 | ||
|
|
a5b95728bf | ||
|
|
c742501b80 | ||
|
|
9c1de27562 | ||
|
|
fc15ef6967 | ||
|
|
eaf0259c3d | ||
|
|
fe2ce03ac1 | ||
|
|
70585ff32e | ||
|
|
a479c867c4 | ||
|
|
74f1b51b38 | ||
|
|
0ad9b83623 | ||
|
|
631d991d8d | ||
|
|
1be4a57bd4 | ||
|
|
76a6119584 | ||
|
|
add95292ce | ||
|
|
18a9e39be6 | ||
|
|
18934bcc69 | ||
|
|
98ff00bc62 | ||
|
|
4292d88a92 | ||
|
|
a8af24d7ca | ||
|
|
efa0fc2b09 | ||
|
|
ebb2918a88 | ||
|
|
4fb3a2e0a0 | ||
|
|
607039f043 | ||
|
|
fb379b63ec | ||
|
|
697161beb1 | ||
|
|
1e7b76005c | ||
|
|
0a661596b3 | ||
|
|
934543b595 | ||
|
|
55b7cf21cc | ||
|
|
3549fc07f6 | ||
|
|
ecd84d7c43 | ||
|
|
c2b2b059e6 | ||
|
|
6ff5a1db42 | ||
|
|
2bc68707b5 | ||
|
|
0c9376039c | ||
|
|
e1fe3ca14a |
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@@ -17,7 +17,7 @@ body:
|
||||
What version of NetBox are you currently running? (If you don't have access to the most
|
||||
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
|
||||
before opening a bug report to see if your issue has already been addressed.)
|
||||
placeholder: v2.11.7
|
||||
placeholder: v2.11.12
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v2.11.7
|
||||
placeholder: v2.11.12
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v3
|
||||
- uses: actions/stale@v4
|
||||
with:
|
||||
close-issue-message: >
|
||||
This issue has been automatically closed due to lack of activity. In an
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,6 +2,8 @@
|
||||
*.swp
|
||||
/netbox/netbox/configuration.py
|
||||
/netbox/netbox/ldap_config.py
|
||||
/netbox/project-static/.cache
|
||||
/netbox/project-static/node_modules
|
||||
/netbox/reports/*
|
||||
!/netbox/reports/__init__.py
|
||||
/netbox/scripts/*
|
||||
|
||||
@@ -160,17 +160,20 @@ accumulating a large backlog of work.
|
||||
The core maintainers group has chosen to make use of GitHub's [Stale bot](https://github.com/apps/stale)
|
||||
to aid in issue management.
|
||||
|
||||
* Issues will be marked as stale after 45 days of no activity.
|
||||
* Then after 15 more days of inactivity, the issue will be closed.
|
||||
* Issues will be marked as stale after 60 days of no activity.
|
||||
* If the stable label is not removed in the following 30 days, the issue will
|
||||
be closed automatically.
|
||||
* Any issue bearing one of the following labels will be exempt from all Stale
|
||||
bot actions:
|
||||
* `status: accepted`
|
||||
* `status: blocked`
|
||||
* `status: needs milestone`
|
||||
|
||||
It is natural that some new issues get more attention than others. Stale bot
|
||||
helps bring renewed attention to potentially valuable issues that may have been
|
||||
overlooked.
|
||||
It is natural that some new issues get more attention than others. The stale
|
||||
bot helps bring renewed attention to potentially valuable issues that may have
|
||||
been overlooked. **Do not** comment on an issue that has been marked stale in
|
||||
an effort to circumvent the bot: Doing so will not remove the stale label.
|
||||
(Stale labels can be removed only by maintainers.)
|
||||
|
||||
## Maintainer Guidance
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl ipv6only=off;
|
||||
|
||||
# CHANGE THIS TO YOUR SERVER'S NAME
|
||||
server_name netbox.example.com;
|
||||
@@ -23,7 +23,7 @@ server {
|
||||
|
||||
server {
|
||||
# Redirect HTTP traffic to HTTPS
|
||||
listen 80;
|
||||
listen [::]:80 ipv6only=off;
|
||||
server_name _;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# Caching
|
||||
|
||||
NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter (15 minutes by default). Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
|
||||
NetBox supports database query caching using [django-cacheops](https://github.com/Suor/django-cacheops) and Redis. When a query is made, the results are cached in Redis for a short period of time, as defined by the [CACHE_TIMEOUT](../configuration/optional-settings.md#cache_timeout) parameter. Within that time, all recurrences of that specific query will return the pre-fetched results from the cache.
|
||||
|
||||
!!! warning
|
||||
In NetBox v2.11.10 and later queryset caching is disabled by default, and must be configured.
|
||||
|
||||
If a change is made to any of the objects returned by the query within that time, or if the timeout expires, the results are automatically invalidated and the next request for those results will be sent to the database.
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ When viewing a device named Router4, this link would render as:
|
||||
|
||||
Custom links appear as buttons at the top right corner of the page. Numeric weighting can be used to influence the ordering of links.
|
||||
|
||||
!!! warning
|
||||
Custom links rely on user-created code to generate arbitrary HTML output, which may be dangerous. Only grant permission to create or modify custom links to trusted users.
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context data is available within the template when rendering a custom link's text or URL.
|
||||
|
||||
@@ -4,10 +4,13 @@ NetBox allows users to define custom templates that can be used when exporting o
|
||||
|
||||
Each export template is associated with a certain type of object. For instance, if you create an export template for VLANs, your custom template will appear under the "Export" button on the VLANs list. Each export template must have a name, and may optionally designate a specific export [MIME type](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types) and/or file extension.
|
||||
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
|
||||
!!! note
|
||||
The name `table` is reserved for internal use.
|
||||
|
||||
Export templates must be written in [Jinja2](https://jinja.palletsprojects.com/).
|
||||
!!! warning
|
||||
Export templates are rendered using user-submitted code, which may pose security risks under certain conditions. Only grant permission to create or modify export templates to trusted users.
|
||||
|
||||
The list of objects returned from the database when rendering an export template is stored in the `queryset` variable, which you'll typically want to iterate through using a `for` loop. Object properties can be access by name. For example:
|
||||
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
A webhook is a mechanism for conveying to some external system a change that took place in NetBox. For example, you may want to notify a monitoring system whenever the status of a device is updated in NetBox. This can be done by creating a webhook for the device model in NetBox and identifying the webhook receiver. When NetBox detects a change to a device, an HTTP request containing the details of the change and who made it be sent to the specified receiver. Webhooks are configured in the admin UI under Extras > Webhooks.
|
||||
|
||||
!!! warning
|
||||
Webhooks support the inclusion of user-submitted code to generate custom headers and payloads, which may pose security risks under certain conditions. Only grant permission to create or modify webhooks to trusted users.
|
||||
|
||||
## Configuration
|
||||
|
||||
* **Name** - A unique name for the webhook. The name is not included with outbound messages.
|
||||
|
||||
@@ -54,9 +54,9 @@ BASE_PATH = 'netbox/'
|
||||
|
||||
## CACHE_TIMEOUT
|
||||
|
||||
Default: 900
|
||||
Default: 0 (disabled)
|
||||
|
||||
The number of seconds that cache entries will be retained before expiring.
|
||||
The number of seconds that cached database queries will be retained before expiring.
|
||||
|
||||
---
|
||||
|
||||
@@ -257,6 +257,16 @@ LOGGING = {
|
||||
|
||||
---
|
||||
|
||||
## LOGIN_PERSISTENCE
|
||||
|
||||
Default: False
|
||||
|
||||
If true, the lifetime of a user's authentication session will be automatically reset upon each valid request. For example, if [`LOGIN_TIMEOUT`](#login_timeout) is configured to 14 days (the default), and a user whose session is due to expire in five days makes a NetBox request (with a valid session cookie), the session's lifetime will be reset to 14 days.
|
||||
|
||||
Note that enabling this setting causes NetBox to update a user's session in the database (or file, as configured per [`SESSION_FILE_PATH`](#session_file_path)) with each request, which may introduce significant overhead in very active environments. It also permits an active user to remain authenticated to NetBox indefinitely.
|
||||
|
||||
---
|
||||
|
||||
## LOGIN_REQUIRED
|
||||
|
||||
Default: False
|
||||
|
||||
85
docs/development/adding-models.md
Normal file
85
docs/development/adding-models.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# Adding Models
|
||||
|
||||
## 1. Define the model class
|
||||
|
||||
Models within each app are stored in either `models.py` or within a submodule under the `models/` directory. When creating a model, be sure to subclass the [appropriate base model](models.md) from `netbox.models`. This will typically be PrimaryModel or OrganizationalModel. Remember to add the model class to the `__all__` listing for the module.
|
||||
|
||||
Each model should define, at a minimum:
|
||||
|
||||
* A `__str__()` method returning a user-friendly string representation of the instance
|
||||
* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`)
|
||||
* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
|
||||
|
||||
## 2. Define field choices
|
||||
|
||||
If the model has one or more fields with static choices, define those choices in `choices.py` by subclassing `utilities.choices.ChoiceSet`.
|
||||
|
||||
## 3. Generate database migrations
|
||||
|
||||
Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations.
|
||||
|
||||
!!! info
|
||||
Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations.
|
||||
|
||||
## 4. Add all standard views
|
||||
|
||||
Most models will need view classes created in `views.py` to serve the following operations:
|
||||
|
||||
* List view
|
||||
* Detail view
|
||||
* Edit view
|
||||
* Delete view
|
||||
* Bulk import
|
||||
* Bulk edit
|
||||
* Bulk delete
|
||||
|
||||
## 5. Add URL paths
|
||||
|
||||
Add the relevant URL path for each view created in the previous step to `urls.py`.
|
||||
|
||||
## 6. Create the FilterSet
|
||||
|
||||
Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
|
||||
|
||||
Every model FilterSet should define a `q` filter to support general search queries.
|
||||
|
||||
## 7. Create the table
|
||||
|
||||
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
|
||||
|
||||
## 8. Create the object template
|
||||
|
||||
Create the HTML template for the object view. (The other views each typically employ a generic template.) This template should extend `generic/object.html`.
|
||||
|
||||
## 9. Add the model to the navigation menu
|
||||
|
||||
For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`.
|
||||
|
||||
## 10. REST API components
|
||||
|
||||
Create the following for each model:
|
||||
|
||||
* Detailed (full) model serializer in `api/serializers.py`
|
||||
* Nested serializer in `api/nested_serializers.py`
|
||||
* API view in `api/views.py`
|
||||
* Endpoint route in `api/urls.py`
|
||||
|
||||
## 11. GraphQL API components (v3.0+)
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
## 12. Add tests
|
||||
|
||||
Add tests for the following:
|
||||
|
||||
* UI views
|
||||
* API views
|
||||
* Filter sets
|
||||
|
||||
## 13. Documentation
|
||||
|
||||
Create a new documentation page for the model in `docs/models/<app_label>/<model_name>.md`. Include this file under the "features" documentation where appropriate.
|
||||
|
||||
Also add your model to the index in `docs/development/models.md`.
|
||||
@@ -1,4 +1,4 @@
|
||||

|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
|
||||
# What is NetBox?
|
||||
|
||||
|
||||
@@ -11,13 +11,13 @@ This section entails the installation and configuration of a local PostgreSQL da
|
||||
|
||||
```no-highlight
|
||||
sudo apt update
|
||||
sudo apt install -y postgresql libpq-dev
|
||||
sudo apt install -y postgresql
|
||||
```
|
||||
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y postgresql-server libpq-devel
|
||||
sudo yum install -y postgresql-server
|
||||
sudo postgresql-setup --initdb
|
||||
```
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Begin by installing all system packages required by NetBox and its dependencies.
|
||||
=== "CentOS"
|
||||
|
||||
```no-highlight
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel openssl-devel redhat-rpm-config
|
||||
sudo yum install -y gcc python36 python36-devel python3-pip libxml2-devel libxslt-devel libffi-devel libpq-devel openssl-devel redhat-rpm-config
|
||||
```
|
||||
|
||||
Before continuing with either platform, update pip (Python's package management tool) to its latest release:
|
||||
@@ -73,6 +73,11 @@ Next, clone the **master** branch of the NetBox GitHub repository into the curre
|
||||
|
||||
```no-highlight
|
||||
$ sudo git clone -b master https://github.com/netbox-community/netbox.git .
|
||||
```
|
||||
|
||||
The screen below should be the result:
|
||||
|
||||
```
|
||||
Cloning into '.'...
|
||||
remote: Counting objects: 1994, done.
|
||||
remote: Compressing objects: 100% (150/150), done.
|
||||
@@ -262,6 +267,13 @@ Starting development server at http://0.0.0.0:8000/
|
||||
Quit the server with CONTROL-C.
|
||||
```
|
||||
|
||||
!!! note
|
||||
By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts):
|
||||
|
||||
```no-highlight
|
||||
firewall-cmd --zone=public --add-port=8000/tcp
|
||||
```
|
||||
|
||||
Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, <http://127.0.0.1:8000/>. You should be greeted with the NetBox home page.
|
||||
|
||||
!!! danger
|
||||
|
||||
@@ -74,7 +74,7 @@ STARTTLS can be configured by setting `AUTH_LDAP_START_TLS = True` and using the
|
||||
### User Authentication
|
||||
|
||||
!!! info
|
||||
When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
|
||||
When using Windows Server 2012+, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
|
||||
|
||||
```python
|
||||
from django_auth_ldap.config import LDAPSearch
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 35 KiB |
@@ -89,3 +89,58 @@ Restart the WSGI service to load the new plugin:
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
## Removing Plugins
|
||||
|
||||
Follow these steps to completely remove a plugin.
|
||||
|
||||
### Update Configuration
|
||||
|
||||
Remove the plugin from the `PLUGINS` list in `configuration.py`. Also remove any relevant configuration parameters from `PLUGINS_CONFIG`.
|
||||
|
||||
### Remove the Python Package
|
||||
|
||||
Use `pip` to remove the installed plugin:
|
||||
|
||||
```no-highlight
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ pip uninstall <package>
|
||||
```
|
||||
|
||||
### Restart WSGI Service
|
||||
|
||||
Restart the WSGI service:
|
||||
|
||||
```no-highlight
|
||||
# sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
### Drop Database Tables
|
||||
|
||||
!!! note
|
||||
This step is necessary only for plugin which have created one or more database tables (generally through the introduction of new models). Check your plugin's documentation if unsure.
|
||||
|
||||
Enter the PostgreSQL database shell to determine if the plugin has created any SQL tables. Substitute `pluginname` in the example below for the name of the plugin being removed. (You can also run the `\dt` command without a pattern to list _all_ tables.)
|
||||
|
||||
```no-highlight
|
||||
netbox=> \dt pluginname_*
|
||||
List of relations
|
||||
List of relations
|
||||
Schema | Name | Type | Owner
|
||||
--------+----------------+-------+--------
|
||||
public | pluginname_foo | table | netbox
|
||||
public | pluginname_bar | table | netbox
|
||||
(2 rows)
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Exercise extreme caution when removing tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
|
||||
|
||||
Drop each of the listed tables to remove it from the database:
|
||||
|
||||
```no-highlight
|
||||
netbox=> DROP TABLE pluginname_foo;
|
||||
DROP TABLE
|
||||
netbox=> DROP TABLE pluginname_bar;
|
||||
DROP TABLE
|
||||
```
|
||||
|
||||
@@ -1,5 +1,111 @@
|
||||
# NetBox v2.11
|
||||
|
||||
## v2.11.12 (2021-08-23)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6748](https://github.com/netbox-community/netbox/issues/6748) - Add site group filter to devices list
|
||||
* [#6790](https://github.com/netbox-community/netbox/issues/6790) - Recognize a /32 IPv4 address as a child of a /32 IPv4 prefix
|
||||
* [#6872](https://github.com/netbox-community/netbox/issues/6872) - Add table configuration button to child prefixes view
|
||||
* [#6929](https://github.com/netbox-community/netbox/issues/6929) - Introduce `LOGIN_PERSISTENCE` configuration parameter to persist user sessions
|
||||
* [#7011](https://github.com/netbox-community/netbox/issues/7011) - Add search field to VM interfaces filter form
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5968](https://github.com/netbox-community/netbox/issues/5968) - Model forms should save empty custom field values as null
|
||||
* [#6326](https://github.com/netbox-community/netbox/issues/6326) - Enable filtering assigned VLANs by group in interface edit form
|
||||
* [#6686](https://github.com/netbox-community/netbox/issues/6686) - Force assignment of null custom field values to objects
|
||||
* [#6776](https://github.com/netbox-community/netbox/issues/6776) - Fix erroneous webhook dispatch on failure to save objects
|
||||
* [#6974](https://github.com/netbox-community/netbox/issues/6974) - Show contextual label for IP address role
|
||||
* [#7012](https://github.com/netbox-community/netbox/issues/7012) - Fix hidden "add components" dropdown on devices list
|
||||
|
||||
---
|
||||
|
||||
## v2.11.11 (2021-08-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6883](https://github.com/netbox-community/netbox/issues/6883) - Add C21 & C22 power types
|
||||
* [#6921](https://github.com/netbox-community/netbox/issues/6921) - Employ a sandbox when rendering Jinja2 code for increased security
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6740](https://github.com/netbox-community/netbox/issues/6740) - Add import button to VM interfaces list
|
||||
* [#6892](https://github.com/netbox-community/netbox/issues/6892) - Fix validation of unit ranges when creating a rack reservation
|
||||
* [#6896](https://github.com/netbox-community/netbox/issues/6896) - Fix validation of IP address assigned as device/VM primary via NAT relation
|
||||
* [#6902](https://github.com/netbox-community/netbox/issues/6902) - Populate device field when cloning device components
|
||||
* [#6908](https://github.com/netbox-community/netbox/issues/6908) - Allow assignment of scope to VLAN groups upon import
|
||||
* [#6909](https://github.com/netbox-community/netbox/issues/6909) - Remove extraneous `site` column from VLAN group import form
|
||||
* [#6910](https://github.com/netbox-community/netbox/issues/6910) - Fix exception on invalid CSV import column name
|
||||
* [#6918](https://github.com/netbox-community/netbox/issues/6918) - Fix return URL persistence when adding multiple objects sequentially
|
||||
* [#6935](https://github.com/netbox-community/netbox/issues/6935) - Remove extraneous columns from inventory item and device bay tables
|
||||
* [#6936](https://github.com/netbox-community/netbox/issues/6936) - Add missing `parent` column to inventory item import form
|
||||
|
||||
---
|
||||
|
||||
## v2.11.10 (2021-07-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#6560](https://github.com/netbox-community/netbox/issues/6560) - Enable CSV import via uploaded file
|
||||
* [#6644](https://github.com/netbox-community/netbox/issues/6644) - Add 6P/4P pass-through port types
|
||||
* [#6771](https://github.com/netbox-community/netbox/issues/6771) - Add count of inventory items to manufacturer view
|
||||
* [#6785](https://github.com/netbox-community/netbox/issues/6785) - Add "hardwired" type for power port types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5442](https://github.com/netbox-community/netbox/issues/5442) - Fix assignment of permissions based on LDAP groups
|
||||
* [#5627](https://github.com/netbox-community/netbox/issues/5627) - Fix filtering of interface connections list
|
||||
* [#6759](https://github.com/netbox-community/netbox/issues/6759) - Fix assignment of parent interfaces for bulk import
|
||||
* [#6773](https://github.com/netbox-community/netbox/issues/6773) - Add missing `display` field to rack unit serializer
|
||||
* [#6774](https://github.com/netbox-community/netbox/issues/6774) - Fix A/Z assignment when swapping circuit terminations
|
||||
* [#6777](https://github.com/netbox-community/netbox/issues/6777) - Fix default value validation for custom text fields
|
||||
* [#6778](https://github.com/netbox-community/netbox/issues/6778) - Rack reservation should display rack's location
|
||||
* [#6780](https://github.com/netbox-community/netbox/issues/6780) - Include rack location in navigation breadcrumbs
|
||||
* [#6794](https://github.com/netbox-community/netbox/issues/6794) - Fix device name display on device status view
|
||||
* [#6812](https://github.com/netbox-community/netbox/issues/6812) - Limit reported prefix utilization to 100%
|
||||
* [#6822](https://github.com/netbox-community/netbox/issues/6822) - Use consistent maximum value for interface MTU
|
||||
|
||||
### Other Changes
|
||||
|
||||
* [#6781](https://github.com/netbox-community/netbox/issues/6781) - Database query caching is now disabled by default
|
||||
|
||||
---
|
||||
|
||||
## v2.11.9 (2021-07-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6456](https://github.com/netbox-community/netbox/issues/6456) - API schema type should be boolean for `_occupied` on cable termination models
|
||||
* [#6710](https://github.com/netbox-community/netbox/issues/6710) - Fix assignment of VM interface parent via REST API
|
||||
* [#6714](https://github.com/netbox-community/netbox/issues/6714) - Fix rendering of device type component creation forms
|
||||
|
||||
---
|
||||
|
||||
## v2.11.8 (2021-07-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5503](https://github.com/netbox-community/netbox/issues/5503) - Annotate short date & time fields with their longer form
|
||||
* [#6138](https://github.com/netbox-community/netbox/issues/6138) - Add an `empty` filter modifier for character fields
|
||||
* [#6200](https://github.com/netbox-community/netbox/issues/6200) - Add rack reservations to global search
|
||||
* [#6368](https://github.com/netbox-community/netbox/issues/6368) - Enable virtual chassis assignment during bulk import of devices
|
||||
* [#6620](https://github.com/netbox-community/netbox/issues/6620) - Show assigned VMs count under device role view
|
||||
* [#6666](https://github.com/netbox-community/netbox/issues/6666) - Show management-only status under interface detail view
|
||||
* [#6667](https://github.com/netbox-community/netbox/issues/6667) - Display VM memory as GB/TB as appropriate
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6626](https://github.com/netbox-community/netbox/issues/6626) - Fix site field on VM search form; add site group
|
||||
* [#6637](https://github.com/netbox-community/netbox/issues/6637) - Fix group assignment in "available VLANs" link under VLAN group view
|
||||
* [#6640](https://github.com/netbox-community/netbox/issues/6640) - Disallow numeric values in custom text fields
|
||||
* [#6652](https://github.com/netbox-community/netbox/issues/6652) - Fix exception when adding components in bulk to multiple devices
|
||||
* [#6676](https://github.com/netbox-community/netbox/issues/6676) - Fix device/VM counts per cluster under cluster type/group views
|
||||
* [#6680](https://github.com/netbox-community/netbox/issues/6680) - Allow setting custom field values for VM interfaces on initial creation
|
||||
* [#6695](https://github.com/netbox-community/netbox/issues/6695) - Fix exception when importing device type with invalid front port definition
|
||||
|
||||
---
|
||||
|
||||
## v2.11.7 (2021-06-16)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -61,27 +61,48 @@ These lookup expressions can be applied by adding a suffix to the desired field'
|
||||
|
||||
Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
|
||||
|
||||
- `n` - not equal to (negation)
|
||||
- `lt` - less than
|
||||
- `lte` - less than or equal
|
||||
- `gt` - greater than
|
||||
- `gte` - greater than or equal
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `lt` | Less than |
|
||||
| `lte` | Less than or equal to |
|
||||
| `gt` | Greater than |
|
||||
| `gte` | Greater than or equal to |
|
||||
|
||||
Here is an example of a numeric field lookup expression that will return all VLANs with a VLAN ID greater than 900:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/vlans/?vid__gt=900
|
||||
```
|
||||
|
||||
### String Fields
|
||||
|
||||
String based (char) fields (Name, Address, etc) support these lookup expressions:
|
||||
|
||||
- `n` - not equal to (negation)
|
||||
- `ic` - case insensitive contains
|
||||
- `nic` - negated case insensitive contains
|
||||
- `isw` - case insensitive starts with
|
||||
- `nisw` - negated case insensitive starts with
|
||||
- `iew` - case insensitive ends with
|
||||
- `niew` - negated case insensitive ends with
|
||||
- `ie` - case insensitive exact match
|
||||
- `nie` - negated case insensitive exact match
|
||||
| Filter | Description |
|
||||
|--------|-------------|
|
||||
| `n` | Not equal to |
|
||||
| `ic` | Contains (case-insensitive) |
|
||||
| `nic` | Does not contain (case-insensitive) |
|
||||
| `isw` | Starts with (case-insensitive) |
|
||||
| `nisw` | Does not start with (case-insensitive) |
|
||||
| `iew` | Ends with (case-insensitive) |
|
||||
| `niew` | Does not end with (case-insensitive) |
|
||||
| `ie` | Exact match (case-insensitive) |
|
||||
| `nie` | Inverse exact match (case-insensitive) |
|
||||
| `empty` | Is empty (boolean) |
|
||||
|
||||
Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name:
|
||||
|
||||
```no-highlight
|
||||
GET /api/dcim/devices/?name__ic=switch
|
||||
```
|
||||
|
||||
### Foreign Keys & Other Fields
|
||||
|
||||
Certain other fields, namely foreign key relationships support just the negation
|
||||
expression: `n`.
|
||||
expression: `n`. Here is an example of a lookup expression on a foreign key, it would return all the VLANs that don't have a VLAN Group ID of 3203:
|
||||
|
||||
```no-highlight
|
||||
GET /api/ipam/vlans/?group_id__n=3203
|
||||
```
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
site_name: NetBox Documentation
|
||||
site_url: https://netbox.readthedocs.io/
|
||||
repo_name: netbox-community/netbox
|
||||
repo_url: https://github.com/netbox-community/netbox
|
||||
python:
|
||||
install:
|
||||
- requirements: docs/requirements.txt
|
||||
theme:
|
||||
name: material
|
||||
name: material
|
||||
icon:
|
||||
repo: fontawesome/brands/github
|
||||
extra_css:
|
||||
- extra.css
|
||||
markdown_extensions:
|
||||
- admonition
|
||||
- attr_list
|
||||
- markdown_include.include:
|
||||
headingOffset: 1
|
||||
- pymdownx.emoji:
|
||||
@@ -76,6 +80,7 @@ nav:
|
||||
- Getting Started: 'development/getting-started.md'
|
||||
- Style Guide: 'development/style-guide.md'
|
||||
- Models: 'development/models.md'
|
||||
- Adding Models: 'development/adding-models.md'
|
||||
- Extending Models: 'development/extending-models.md'
|
||||
- Application Registry: 'development/application-registry.md'
|
||||
- User Preferences: 'development/user-preferences.md'
|
||||
|
||||
@@ -287,6 +287,10 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
termination_z.save()
|
||||
termination_a.term_side = 'Z'
|
||||
termination_a.save()
|
||||
circuit.refresh_from_db()
|
||||
circuit.termination_a = termination_z
|
||||
circuit.termination_z = termination_a
|
||||
circuit.save()
|
||||
elif termination_a:
|
||||
termination_a.term_side = 'Z'
|
||||
termination_a.save()
|
||||
@@ -300,9 +304,6 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
circuit.termination_z = None
|
||||
circuit.save()
|
||||
|
||||
print(f'term A: {circuit.termination_a}')
|
||||
print(f'term Z: {circuit.termination_z}')
|
||||
|
||||
messages.success(request, f"Swapped terminations for circuit {circuit}.")
|
||||
return redirect('circuits:circuit', pk=circuit.pk)
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ from .nested_serializers import *
|
||||
class CableTerminationSerializer(serializers.ModelSerializer):
|
||||
cable_peer_type = serializers.SerializerMethodField(read_only=True)
|
||||
cable_peer = serializers.SerializerMethodField(read_only=True)
|
||||
_occupied = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_cable_peer_type(self, obj):
|
||||
if obj._cable_peer is not None:
|
||||
@@ -42,6 +43,10 @@ class CableTerminationSerializer(serializers.ModelSerializer):
|
||||
return serializer(obj._cable_peer, context=context).data
|
||||
return None
|
||||
|
||||
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
|
||||
def get__occupied(self, obj):
|
||||
return obj._occupied
|
||||
|
||||
|
||||
class ConnectedEndpointSerializer(serializers.ModelSerializer):
|
||||
connected_endpoint_type = serializers.SerializerMethodField(read_only=True)
|
||||
@@ -205,6 +210,10 @@ class RackUnitSerializer(serializers.Serializer):
|
||||
face = ChoiceField(choices=DeviceFaceChoices, read_only=True)
|
||||
device = NestedDeviceSerializer(read_only=True)
|
||||
occupied = serializers.BooleanField(read_only=True)
|
||||
display = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
def get_display(self, obj):
|
||||
return obj['name']
|
||||
|
||||
|
||||
class RackReservationSerializer(PrimaryModelSerializer):
|
||||
|
||||
@@ -592,11 +592,9 @@ class PowerConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
|
||||
class InterfaceConnectionViewSet(ListModelMixin, GenericViewSet):
|
||||
queryset = Interface.objects.prefetch_related('device', '_path').filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
_path__destination_type__app_label='dcim',
|
||||
_path__destination_type__model='interface',
|
||||
_path__destination_id__isnull=False,
|
||||
pk__lt=F('_path__destination_id')
|
||||
_path__destination_id__isnull=False
|
||||
)
|
||||
serializer_class = serializers.InterfaceConnectionSerializer
|
||||
filterset_class = filtersets.InterfaceConnectionFilterSet
|
||||
|
||||
@@ -252,6 +252,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C14 = 'iec-60320-c14'
|
||||
TYPE_IEC_C16 = 'iec-60320-c16'
|
||||
TYPE_IEC_C20 = 'iec-60320-c20'
|
||||
TYPE_IEC_C22 = 'iec-60320-c22'
|
||||
# IEC 60309
|
||||
TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h'
|
||||
TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h'
|
||||
@@ -341,6 +342,8 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_DC = 'dc-terminal'
|
||||
# Proprietary
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -349,6 +352,7 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C14, 'C14'),
|
||||
(TYPE_IEC_C16, 'C16'),
|
||||
(TYPE_IEC_C20, 'C20'),
|
||||
(TYPE_IEC_C22, 'C22'),
|
||||
)),
|
||||
('IEC 60309', (
|
||||
(TYPE_IEC_PNE4H, 'P+N+E 4H'),
|
||||
@@ -447,6 +451,9 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
('Proprietary', (
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@@ -462,6 +469,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_C13 = 'iec-60320-c13'
|
||||
TYPE_IEC_C15 = 'iec-60320-c15'
|
||||
TYPE_IEC_C19 = 'iec-60320-c19'
|
||||
TYPE_IEC_C21 = 'iec-60320-c21'
|
||||
# IEC 60309
|
||||
TYPE_IEC_PNE4H = 'iec-60309-p-n-e-4h'
|
||||
TYPE_IEC_PNE6H = 'iec-60309-p-n-e-6h'
|
||||
@@ -545,6 +553,8 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
# Proprietary
|
||||
TYPE_HDOT_CX = 'hdot-cx'
|
||||
TYPE_SAF_D_GRID = 'saf-d-grid'
|
||||
# Other
|
||||
TYPE_HARDWIRED = 'hardwired'
|
||||
|
||||
CHOICES = (
|
||||
('IEC 60320', (
|
||||
@@ -553,6 +563,7 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_C13, 'C13'),
|
||||
(TYPE_IEC_C15, 'C15'),
|
||||
(TYPE_IEC_C19, 'C19'),
|
||||
(TYPE_IEC_C21, 'C21'),
|
||||
)),
|
||||
('IEC 60309', (
|
||||
(TYPE_IEC_PNE4H, 'P+N+E 4H'),
|
||||
@@ -645,6 +656,9 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_HDOT_CX, 'HDOT Cx'),
|
||||
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
|
||||
)),
|
||||
('Other', (
|
||||
(TYPE_HARDWIRED, 'Hardwired'),
|
||||
)),
|
||||
)
|
||||
|
||||
|
||||
@@ -917,6 +931,11 @@ class PortTypeChoices(ChoiceSet):
|
||||
TYPE_8P6C = '8p6c'
|
||||
TYPE_8P4C = '8p4c'
|
||||
TYPE_8P2C = '8p2c'
|
||||
TYPE_6P6C = '6p6c'
|
||||
TYPE_6P4C = '6p4c'
|
||||
TYPE_6P2C = '6p2c'
|
||||
TYPE_4P4C = '4p4c'
|
||||
TYPE_4P2C = '4p2c'
|
||||
TYPE_GG45 = 'gg45'
|
||||
TYPE_TERA4P = 'tera-4p'
|
||||
TYPE_TERA2P = 'tera-2p'
|
||||
@@ -948,6 +967,11 @@ class PortTypeChoices(ChoiceSet):
|
||||
(TYPE_8P6C, '8P6C'),
|
||||
(TYPE_8P4C, '8P4C'),
|
||||
(TYPE_8P2C, '8P2C'),
|
||||
(TYPE_6P6C, '6P6C'),
|
||||
(TYPE_6P4C, '6P4C'),
|
||||
(TYPE_6P2C, '6P2C'),
|
||||
(TYPE_4P4C, '4P4C'),
|
||||
(TYPE_4P2C, '4P2C'),
|
||||
(TYPE_GG45, 'GG45'),
|
||||
(TYPE_TERA4P, 'TERA 4P'),
|
||||
(TYPE_TERA2P, 'TERA 2P'),
|
||||
|
||||
@@ -29,7 +29,7 @@ REARPORT_POSITIONS_MAX = 1024
|
||||
#
|
||||
|
||||
INTERFACE_MTU_MIN = 1
|
||||
INTERFACE_MTU_MAX = 32767 # Max value of a signed 16-bit integer
|
||||
INTERFACE_MTU_MAX = 65536
|
||||
|
||||
VIRTUAL_IFACE_TYPES = [
|
||||
InterfaceTypeChoices.TYPE_VIRTUAL,
|
||||
|
||||
@@ -18,7 +18,7 @@ from extras.forms import (
|
||||
)
|
||||
from extras.models import Tag
|
||||
from ipam.constants import BGP_ASN_MAX, BGP_ASN_MIN
|
||||
from ipam.models import IPAddress, VLAN
|
||||
from ipam.models import IPAddress, VLAN, VLANGroup
|
||||
from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
@@ -102,6 +102,12 @@ class InterfaceCommonForm(forms.Form):
|
||||
required=False,
|
||||
label='MAC address'
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
max_value=INTERFACE_MTU_MAX,
|
||||
label='MTU'
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
@@ -1878,8 +1884,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
|
||||
)
|
||||
rear_port = forms.ModelChoiceField(
|
||||
queryset=RearPortTemplate.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False
|
||||
to_field_name='name'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@@ -2236,6 +2241,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
|
||||
choices=DeviceStatusChoices,
|
||||
help_text='Operational status'
|
||||
)
|
||||
virtual_chassis = CSVModelChoiceField(
|
||||
queryset=VirtualChassis.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Virtual chassis'
|
||||
)
|
||||
cluster = CSVModelChoiceField(
|
||||
queryset=Cluster.objects.all(),
|
||||
to_field_name='name',
|
||||
@@ -2246,6 +2257,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
|
||||
class Meta:
|
||||
fields = []
|
||||
model = Device
|
||||
help_texts = {
|
||||
'vc_position': 'Virtual chassis position',
|
||||
'vc_priority': 'Virtual chassis priority',
|
||||
}
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
super().__init__(data, *args, **kwargs)
|
||||
@@ -2284,7 +2299,8 @@ class DeviceCSVForm(BaseDeviceCSVForm):
|
||||
class Meta(BaseDeviceCSVForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'site', 'location', 'rack', 'position', 'face', 'cluster', 'comments',
|
||||
'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
|
||||
'comments',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -2319,7 +2335,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
|
||||
class Meta(BaseDeviceCSVForm.Meta):
|
||||
fields = [
|
||||
'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
|
||||
'parent', 'device_bay', 'cluster', 'comments',
|
||||
'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@@ -2405,8 +2421,8 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF
|
||||
class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldFilterForm):
|
||||
model = Device
|
||||
field_order = [
|
||||
'q', 'region_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id', 'tenant_id',
|
||||
'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
|
||||
'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'status', 'role_id', 'tenant_group_id',
|
||||
'tenant_id', 'manufacturer_id', 'device_type_id', 'asset_tag', 'mac_address', 'has_primary_ip',
|
||||
]
|
||||
q = forms.CharField(
|
||||
required=False,
|
||||
@@ -2417,11 +2433,17 @@ class DeviceFilterForm(BootstrapMixin, LocalConfigContextFilterForm, TenancyFilt
|
||||
required=False,
|
||||
label=_('Region')
|
||||
)
|
||||
site_group_id = DynamicModelMultipleChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region_id'
|
||||
'region_id': '$region_id',
|
||||
'group_id': '$site_group_id',
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
@@ -3087,15 +3109,26 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
|
||||
'type': 'lag',
|
||||
}
|
||||
)
|
||||
vlan_group = DynamicModelChoiceField(
|
||||
queryset=VLANGroup.objects.all(),
|
||||
required=False,
|
||||
label='VLAN group'
|
||||
)
|
||||
untagged_vlan = DynamicModelChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
label='Untagged VLAN'
|
||||
label='Untagged VLAN',
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
}
|
||||
)
|
||||
tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
label='Tagged VLANs'
|
||||
label='Tagged VLANs',
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
}
|
||||
)
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
queryset=Tag.objects.all(),
|
||||
@@ -3163,12 +3196,6 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
|
||||
'type': 'lag',
|
||||
}
|
||||
)
|
||||
mtu = forms.IntegerField(
|
||||
required=False,
|
||||
min_value=INTERFACE_MTU_MIN,
|
||||
max_value=INTERFACE_MTU_MAX,
|
||||
label='MTU'
|
||||
)
|
||||
mac_address = forms.CharField(
|
||||
required=False,
|
||||
label='MAC Address'
|
||||
@@ -3368,13 +3395,18 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
|
||||
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis),
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
self.fields['parent'].queryset = Interface.objects.filter(
|
||||
Q(device=device) | Q(device__virtual_chassis=device.virtual_chassis)
|
||||
)
|
||||
elif device:
|
||||
self.fields['lag'].queryset = Interface.objects.filter(
|
||||
device=device,
|
||||
type=InterfaceTypeChoices.TYPE_LAG
|
||||
)
|
||||
self.fields['parent'].queryset = Interface.objects.filter(device=device)
|
||||
else:
|
||||
self.fields['lag'].queryset = Interface.objects.none()
|
||||
self.fields['parent'].queryset = Interface.objects.none()
|
||||
|
||||
def clean_enabled(self):
|
||||
# Make sure enabled is True when it's not included in the uploaded data
|
||||
@@ -3837,11 +3869,32 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
|
||||
to_field_name='name',
|
||||
required=False
|
||||
)
|
||||
parent = CSVModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
to_field_name='name',
|
||||
required=False,
|
||||
help_text='Parent inventory item'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = InventoryItem
|
||||
fields = InventoryItem.csv_headers
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit parent choices to inventory items belonging to this device
|
||||
device = None
|
||||
if self.is_bound and 'device' in self.data:
|
||||
try:
|
||||
device = self.fields['device'].to_python(self.data['device'])
|
||||
except forms.ValidationError:
|
||||
pass
|
||||
if device:
|
||||
self.fields['parent'].queryset = InventoryItem.objects.filter(device=device)
|
||||
else:
|
||||
self.fields['parent'].queryset = InventoryItem.objects.none()
|
||||
|
||||
|
||||
class InventoryItemBulkCreateForm(
|
||||
form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
|
||||
|
||||
@@ -290,19 +290,24 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device_type != self.device_type:
|
||||
raise ValidationError(
|
||||
"Rear port ({}) must belong to the same device type".format(self.rear_port)
|
||||
)
|
||||
try:
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError(
|
||||
"Invalid rear port position ({}); rear port {} has only {} positions".format(
|
||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
||||
# Validate rear port assignment
|
||||
if self.rear_port.device_type != self.device_type:
|
||||
raise ValidationError(
|
||||
"Rear port ({}) must belong to the same device type".format(self.rear_port)
|
||||
)
|
||||
)
|
||||
|
||||
# Validate rear port position assignment
|
||||
if self.rear_port_position > self.rear_port.positions:
|
||||
raise ValidationError(
|
||||
"Invalid rear port position ({}); rear port {} has only {} positions".format(
|
||||
self.rear_port_position, self.rear_port.name, self.rear_port.positions
|
||||
)
|
||||
)
|
||||
|
||||
except RearPortTemplate.DoesNotExist:
|
||||
pass
|
||||
|
||||
def instantiate(self, device):
|
||||
if self.rear_port:
|
||||
|
||||
@@ -230,6 +230,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
|
||||
)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
|
||||
clone_fields = ['device', 'type', 'speed']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -273,6 +274,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
|
||||
)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'speed', 'mark_connected', 'description']
|
||||
clone_fields = ['device', 'type', 'speed']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -324,6 +326,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
|
||||
csv_headers = [
|
||||
'device', 'name', 'label', 'type', 'mark_connected', 'maximum_draw', 'allocated_draw', 'description',
|
||||
]
|
||||
clone_fields = ['device', 'maximum_draw', 'allocated_draw']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -434,6 +437,7 @@ class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
|
||||
)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'power_port', 'feed_leg', 'description']
|
||||
clone_fields = ['device', 'type', 'power_port', 'feed_leg']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -483,7 +487,10 @@ class BaseInterface(models.Model):
|
||||
mtu = models.PositiveIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(65536)],
|
||||
validators=[
|
||||
MinValueValidator(INTERFACE_MTU_MIN),
|
||||
MaxValueValidator(INTERFACE_MTU_MAX)
|
||||
],
|
||||
verbose_name='MTU'
|
||||
)
|
||||
mode = models.CharField(
|
||||
@@ -574,6 +581,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
|
||||
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'mtu',
|
||||
'mgmt_only', 'description', 'mode',
|
||||
]
|
||||
clone_fields = ['device', 'parent', 'lag', 'type', 'mgmt_only']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', CollateAsChar('_name'))
|
||||
@@ -708,6 +716,7 @@ class FrontPort(ComponentModel, CableTermination):
|
||||
csv_headers = [
|
||||
'device', 'name', 'label', 'type', 'mark_connected', 'rear_port', 'rear_port_position', 'description',
|
||||
]
|
||||
clone_fields = ['device', 'type']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -764,6 +773,7 @@ class RearPort(ComponentModel, CableTermination):
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
clone_fields = ['device', 'type', 'positions']
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'type', 'mark_connected', 'positions', 'description']
|
||||
|
||||
@@ -815,6 +825,7 @@ class DeviceBay(ComponentModel):
|
||||
)
|
||||
|
||||
csv_headers = ['device', 'name', 'label', 'installed_device', 'description']
|
||||
clone_fields = ['device']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device', '_name')
|
||||
@@ -910,6 +921,7 @@ class InventoryItem(MPTTModel, ComponentModel):
|
||||
csv_headers = [
|
||||
'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
|
||||
]
|
||||
clone_fields = ['device', 'parent', 'manufacturer', 'part_id']
|
||||
|
||||
class Meta:
|
||||
ordering = ('device__id', 'parent__id', '_name')
|
||||
|
||||
@@ -232,10 +232,6 @@ class DeviceComponentTable(BaseTable):
|
||||
linkify=True,
|
||||
order_by=('_name',)
|
||||
)
|
||||
cable = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
mark_connected = BooleanColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
order_by = ('device', 'name')
|
||||
|
||||
@@ -1211,8 +1211,9 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
{
|
||||
'device': device.pk,
|
||||
'name': 'Interface 6',
|
||||
'type': '1000base-t',
|
||||
'type': 'virtual',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'parent': interfaces[0].pk,
|
||||
'tagged_vlans': [vlans[0].pk, vlans[1].pk],
|
||||
'untagged_vlan': vlans[2].pk,
|
||||
},
|
||||
|
||||
@@ -1023,6 +1023,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
VirtualChassis.objects.create(name='Virtual Chassis 1')
|
||||
|
||||
cls.form_data = {
|
||||
'device_type': devicetypes[1].pk,
|
||||
'device_role': deviceroles[1].pk,
|
||||
@@ -1048,10 +1050,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face",
|
||||
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front",
|
||||
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front",
|
||||
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front",
|
||||
"device_role,manufacturer,device_type,status,name,site,location,rack,position,face,virtual_chassis,vc_position,vc_priority",
|
||||
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front,Virtual Chassis 1,1,10",
|
||||
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front,Virtual Chassis 1,2,20",
|
||||
"Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
@@ -1462,7 +1464,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
'enabled': False,
|
||||
'lag': interfaces[3].pk,
|
||||
'mac_address': EUI('01:02:03:04:05:06'),
|
||||
'mtu': 2000,
|
||||
'mtu': 65000,
|
||||
'mgmt_only': True,
|
||||
'description': 'A front port',
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
@@ -1734,10 +1736,10 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"device,name",
|
||||
"Device 1,Inventory Item 4",
|
||||
"Device 1,Inventory Item 5",
|
||||
"Device 1,Inventory Item 6",
|
||||
"device,name,parent",
|
||||
"Device 1,Inventory Item 4,Inventory Item 1",
|
||||
"Device 1,Inventory Item 5,Inventory Item 2",
|
||||
"Device 1,Inventory Item 6,Inventory Item 3",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -695,6 +695,9 @@ class ManufacturerView(generic.ObjectView):
|
||||
).annotate(
|
||||
instance_count=count_related(Device, 'device_type')
|
||||
)
|
||||
inventory_items = InventoryItem.objects.restrict(request.user, 'view').filter(
|
||||
manufacturer=instance
|
||||
)
|
||||
|
||||
devicetypes_table = tables.DeviceTypeTable(devicetypes)
|
||||
devicetypes_table.columns.hide('manufacturer')
|
||||
@@ -702,6 +705,7 @@ class ManufacturerView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'devicetypes_table': devicetypes_table,
|
||||
'inventory_item_count': inventory_items.count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -1169,6 +1173,8 @@ class DeviceRoleView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'devices_table': devices_table,
|
||||
'device_count': Device.objects.filter(device_role=instance).count(),
|
||||
'virtualmachine_count': VirtualMachine.objects.filter(role=instance).count(),
|
||||
}
|
||||
|
||||
|
||||
@@ -2562,11 +2568,7 @@ class PowerConnectionsListView(generic.ObjectListView):
|
||||
|
||||
|
||||
class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
queryset = Interface.objects.filter(
|
||||
# Avoid duplicate connections by only selecting the lower PK in a connected pair
|
||||
_path__isnull=False,
|
||||
pk__lt=F('_path__destination_id')
|
||||
).order_by('device')
|
||||
queryset = Interface.objects.filter(_path__isnull=False).order_by('device')
|
||||
filterset = filtersets.InterfaceConnectionFilterSet
|
||||
filterset_form = forms.InterfaceConnectionFilterForm
|
||||
table = tables.InterfaceConnectionTable
|
||||
|
||||
@@ -5,4 +5,5 @@ class ExtrasConfig(AppConfig):
|
||||
name = "extras"
|
||||
|
||||
def ready(self):
|
||||
import extras.lookups
|
||||
import extras.signals
|
||||
|
||||
@@ -2,7 +2,7 @@ from contextlib import contextmanager
|
||||
|
||||
from django.db.models.signals import m2m_changed, pre_delete, post_save
|
||||
|
||||
from extras.signals import _handle_changed_object, _handle_deleted_object
|
||||
from extras.signals import clear_webhooks, _clear_webhook_queue, _handle_changed_object, _handle_deleted_object
|
||||
from utilities.utils import curry
|
||||
from .webhooks import flush_webhooks
|
||||
|
||||
@@ -20,11 +20,13 @@ def change_logging(request):
|
||||
# Curry signals receivers to pass the current request
|
||||
handle_changed_object = curry(_handle_changed_object, request, webhook_queue)
|
||||
handle_deleted_object = curry(_handle_deleted_object, request, webhook_queue)
|
||||
clear_webhook_queue = curry(_clear_webhook_queue, webhook_queue)
|
||||
|
||||
# Connect our receivers to the post_save and post_delete signals.
|
||||
post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
|
||||
yield
|
||||
|
||||
@@ -33,6 +35,7 @@ def change_logging(request):
|
||||
post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
|
||||
pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
|
||||
clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
|
||||
|
||||
# Flush queued webhooks to RQ
|
||||
flush_webhooks(webhook_queue)
|
||||
|
||||
@@ -77,7 +77,11 @@ class CustomFieldModelForm(forms.ModelForm):
|
||||
|
||||
# Save custom field data on instance
|
||||
for cf_name in self.custom_fields:
|
||||
self.instance.custom_field_data[cf_name[3:]] = self.cleaned_data.get(cf_name)
|
||||
key = cf_name[3:] # Strip "cf_" from field name
|
||||
value = self.cleaned_data.get(cf_name)
|
||||
empty_values = self.fields[cf_name].empty_values
|
||||
# Convert "empty" values to null
|
||||
self.instance.custom_field_data[key] = value if value not in empty_values else None
|
||||
|
||||
return super().clean()
|
||||
|
||||
|
||||
17
netbox/extras/lookups.py
Normal file
17
netbox/extras/lookups.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from django.db.models import CharField, Lookup
|
||||
|
||||
|
||||
class Empty(Lookup):
|
||||
"""
|
||||
Filter on whether a string is empty.
|
||||
"""
|
||||
lookup_name = 'empty'
|
||||
|
||||
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 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
CharField.register_lookup(Empty)
|
||||
@@ -37,12 +37,10 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
self.end_headers()
|
||||
self.wfile.write(b'Webhook received!\n')
|
||||
|
||||
request_counter += 1
|
||||
|
||||
# Print the request headers to stdout
|
||||
# Print the request headers
|
||||
if self.show_headers:
|
||||
for k, v in self.headers.items():
|
||||
print('{}: {}'.format(k, v))
|
||||
print(f'{k}: {v}')
|
||||
print()
|
||||
|
||||
# Print the request body (if any)
|
||||
@@ -55,8 +53,11 @@ class WebhookHandler(BaseHTTPRequestHandler):
|
||||
else:
|
||||
print('(No body)')
|
||||
|
||||
print(f'Completed request #{request_counter}')
|
||||
print('------------')
|
||||
|
||||
request_counter += 1
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Start a simple listener to display received HTTP requests"
|
||||
|
||||
@@ -120,6 +120,30 @@ class CustomField(BigIDModel):
|
||||
# Cache instance's original name so we can check later whether it has changed
|
||||
self._name = self.name
|
||||
|
||||
def populate_initial_data(self, content_types):
|
||||
"""
|
||||
Populate initial custom field data upon either a) the creation of a new CustomField, or
|
||||
b) the assignment of an existing CustomField to new object types.
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.exclude(**{f'custom_field_data__contains': self.name})
|
||||
for instance in instances:
|
||||
instance.custom_field_data[self.name] = self.default
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||
no longer assigned to a model, or because it has been deleted).
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
instances = model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False})
|
||||
for instance in instances:
|
||||
del(instance.custom_field_data[self.name])
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def rename_object_data(self, old_name, new_name):
|
||||
"""
|
||||
Called when a CustomField has been renamed. Updates all assigned object data.
|
||||
@@ -132,24 +156,14 @@ class CustomField(BigIDModel):
|
||||
instance.custom_field_data[new_name] = instance.custom_field_data.pop(old_name)
|
||||
model.objects.bulk_update(instances, ['custom_field_data'], batch_size=100)
|
||||
|
||||
def remove_stale_data(self, content_types):
|
||||
"""
|
||||
Delete custom field data which is no longer relevant (either because the CustomField is
|
||||
no longer assigned to a model, or because it has been deleted).
|
||||
"""
|
||||
for ct in content_types:
|
||||
model = ct.model_class()
|
||||
for obj in model.objects.filter(**{f'custom_field_data__{self.name}__isnull': False}):
|
||||
del(obj.custom_field_data[self.name])
|
||||
obj.save()
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Validate the field's default value (if any)
|
||||
if self.default is not None:
|
||||
try:
|
||||
self.validate(self.default)
|
||||
default_value = str(self.default) if self.type == CustomFieldTypeChoices.TYPE_TEXT else self.default
|
||||
self.validate(default_value)
|
||||
except ValidationError as err:
|
||||
raise ValidationError({
|
||||
'default': f'Invalid default value "{self.default}": {err.message}'
|
||||
@@ -280,8 +294,10 @@ class CustomField(BigIDModel):
|
||||
if value not in [None, '']:
|
||||
|
||||
# Validate text field
|
||||
if self.type == CustomFieldTypeChoices.TYPE_TEXT and self.validation_regex:
|
||||
if not re.match(self.validation_regex, value):
|
||||
if self.type == CustomFieldTypeChoices.TYPE_TEXT:
|
||||
if type(value) is not str:
|
||||
raise ValidationError(f"Value must be a string.")
|
||||
if self.validation_regex and not re.match(self.validation_regex, value):
|
||||
raise ValidationError(f"Value must match regex '{self.validation_regex}'")
|
||||
|
||||
# Validate integer
|
||||
|
||||
@@ -431,9 +431,8 @@ class JournalEntry(ChangeLoggedModel):
|
||||
verbose_name_plural = 'journal entries'
|
||||
|
||||
def __str__(self):
|
||||
created_date = timezone.localdate(self.created)
|
||||
created_time = timezone.localtime(self.created)
|
||||
return f"{date_format(created_date)} - {time_format(created_time)} ({self.get_kind_display()})"
|
||||
created = timezone.localtime(self.created)
|
||||
return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})"
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse('extras:journalentry', args=[self.pk])
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
import random
|
||||
from datetime import timedelta
|
||||
|
||||
@@ -6,6 +7,7 @@ from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import DEFAULT_DB_ALIAS
|
||||
from django.db.models.signals import m2m_changed, post_save, pre_delete
|
||||
from django.dispatch import Signal
|
||||
from django.utils import timezone
|
||||
from django_prometheus.models import model_deletes, model_inserts, model_updates
|
||||
from prometheus_client import Counter
|
||||
@@ -19,6 +21,10 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
|
||||
# Change logging/webhooks
|
||||
#
|
||||
|
||||
# Define a custom signal that can be sent to clear any queued webhooks
|
||||
clear_webhooks = Signal()
|
||||
|
||||
|
||||
def _handle_changed_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
"""
|
||||
Fires when an object is created or updated.
|
||||
@@ -104,10 +110,28 @@ def _handle_deleted_object(request, webhook_queue, sender, instance, **kwargs):
|
||||
model_deletes.labels(instance._meta.model_name).inc()
|
||||
|
||||
|
||||
def _clear_webhook_queue(webhook_queue, sender, **kwargs):
|
||||
"""
|
||||
Delete any queued webhooks (e.g. because of an aborted bulk transaction)
|
||||
"""
|
||||
logger = logging.getLogger('webhooks')
|
||||
logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
|
||||
|
||||
webhook_queue.clear()
|
||||
|
||||
|
||||
#
|
||||
# Custom fields
|
||||
#
|
||||
|
||||
def handle_cf_added_obj_types(instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
Handle the population of default/null values when a CustomField is added to one or more ContentTypes.
|
||||
"""
|
||||
if action == 'post_add':
|
||||
instance.populate_initial_data(ContentType.objects.filter(pk__in=pk_set))
|
||||
|
||||
|
||||
def handle_cf_removed_obj_types(instance, action, pk_set, **kwargs):
|
||||
"""
|
||||
Handle the cleanup of old custom field data when a CustomField is removed from one or more ContentTypes.
|
||||
@@ -131,9 +155,10 @@ def handle_cf_deleted(instance, **kwargs):
|
||||
instance.remove_stale_data(instance.content_types.all())
|
||||
|
||||
|
||||
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
||||
post_save.connect(handle_cf_renamed, sender=CustomField)
|
||||
pre_delete.connect(handle_cf_deleted, sender=CustomField)
|
||||
m2m_changed.connect(handle_cf_added_obj_types, sender=CustomField.content_types.through)
|
||||
m2m_changed.connect(handle_cf_removed_obj_types, sender=CustomField.content_types.through)
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -42,8 +42,11 @@ class CustomFieldTest(TestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Assign a value to the first Site
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = data['field_value']
|
||||
site.save()
|
||||
|
||||
@@ -73,8 +76,11 @@ class CustomFieldTest(TestCase):
|
||||
cf.save()
|
||||
cf.content_types.set([obj_type])
|
||||
|
||||
# Assign a value to the first Site
|
||||
# Check that the field has a null initial value
|
||||
site = Site.objects.first()
|
||||
self.assertIsNone(site.custom_field_data[cf.name])
|
||||
|
||||
# Assign a value to the first Site
|
||||
site.custom_field_data[cf.name] = 'Option A'
|
||||
site.save()
|
||||
|
||||
|
||||
53
netbox/extras/tests/test_forms.py
Normal file
53
netbox/extras/tests/test_forms.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.forms import SiteForm
|
||||
from dcim.models import Site
|
||||
from extras.choices import CustomFieldTypeChoices
|
||||
from extras.models import CustomField
|
||||
|
||||
|
||||
class CustomFieldModelFormTest(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
obj_type = ContentType.objects.get_for_model(Site)
|
||||
CHOICES = ('A', 'B', 'C')
|
||||
|
||||
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)
|
||||
cf_text.content_types.set([obj_type])
|
||||
|
||||
cf_integer = CustomField.objects.create(name='integer', type=CustomFieldTypeChoices.TYPE_INTEGER)
|
||||
cf_integer.content_types.set([obj_type])
|
||||
|
||||
cf_boolean = CustomField.objects.create(name='boolean', type=CustomFieldTypeChoices.TYPE_BOOLEAN)
|
||||
cf_boolean.content_types.set([obj_type])
|
||||
|
||||
cf_date = CustomField.objects.create(name='date', type=CustomFieldTypeChoices.TYPE_DATE)
|
||||
cf_date.content_types.set([obj_type])
|
||||
|
||||
cf_url = CustomField.objects.create(name='url', type=CustomFieldTypeChoices.TYPE_URL)
|
||||
cf_url.content_types.set([obj_type])
|
||||
|
||||
cf_select = CustomField.objects.create(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=CHOICES)
|
||||
cf_select.content_types.set([obj_type])
|
||||
|
||||
cf_multiselect = CustomField.objects.create(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT,
|
||||
choices=CHOICES)
|
||||
cf_multiselect.content_types.set([obj_type])
|
||||
|
||||
def test_empty_values(self):
|
||||
"""
|
||||
Test that empty custom field values are stored as null
|
||||
"""
|
||||
form = SiteForm({
|
||||
'name': 'Site 1',
|
||||
'slug': 'site-1',
|
||||
'status': 'active',
|
||||
})
|
||||
self.assertTrue(form.is_valid())
|
||||
instance = form.save()
|
||||
|
||||
for field_type, _ in CustomFieldTypeChoices.CHOICES:
|
||||
self.assertIn(field_type, instance.custom_field_data)
|
||||
self.assertIsNone(instance.custom_field_data[field_type])
|
||||
@@ -11,8 +11,8 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import (
|
||||
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, ContentTypeChoiceField, CSVChoiceField,
|
||||
CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableIPAddressField,
|
||||
NumericArrayField, ReturnURLForm, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
CSVContentTypeField, CSVModelChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
|
||||
ExpandableIPAddressField, NumericArrayField, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField,
|
||||
BOOLEAN_WITH_BLANK_CHOICES,
|
||||
)
|
||||
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
|
||||
@@ -682,7 +682,7 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm)
|
||||
# IP addresses
|
||||
#
|
||||
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModelForm):
|
||||
class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
@@ -1238,17 +1238,19 @@ class VLANGroupForm(BootstrapMixin, CustomFieldModelForm):
|
||||
|
||||
|
||||
class VLANGroupCSVForm(CustomFieldModelCSVForm):
|
||||
site = CSVModelChoiceField(
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
to_field_name='name',
|
||||
help_text='Assigned site'
|
||||
)
|
||||
slug = SlugField()
|
||||
scope_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False,
|
||||
label='Scope type (app & model)'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VLANGroup
|
||||
fields = VLANGroup.csv_headers
|
||||
labels = {
|
||||
'scope_id': 'Scope ID',
|
||||
}
|
||||
|
||||
|
||||
class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
|
||||
|
||||
@@ -151,7 +151,7 @@ class NetHostContained(Lookup):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'CAST(HOST(%s) AS INET) << %s' % (lhs, rhs), params
|
||||
return 'CAST(HOST(%s) AS INET) <<= %s' % (lhs, rhs), params
|
||||
|
||||
|
||||
class NetFamily(Transform):
|
||||
|
||||
@@ -181,7 +181,9 @@ class Aggregate(PrimaryModel):
|
||||
"""
|
||||
queryset = Prefix.objects.filter(prefix__net_contained_or_equal=str(self.prefix))
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
@@ -502,14 +504,16 @@ class Prefix(PrimaryModel):
|
||||
vrf=self.vrf
|
||||
)
|
||||
child_prefixes = netaddr.IPSet([p.prefix for p in queryset])
|
||||
return int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
utilization = int(float(child_prefixes.size) / self.prefix.size * 100)
|
||||
else:
|
||||
# Compile an IPSet to avoid counting duplicate IPs
|
||||
child_count = netaddr.IPSet([ip.address.ip for ip in self.get_child_ips()]).size
|
||||
prefix_size = self.prefix.size
|
||||
if self.prefix.version == 4 and self.prefix.prefixlen < 31 and not self.is_pool:
|
||||
prefix_size -= 2
|
||||
return int(float(child_count) / prefix_size * 100)
|
||||
utilization = int(float(child_count) / prefix_size * 100)
|
||||
|
||||
return min(utilization, 100)
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
|
||||
@@ -645,18 +649,15 @@ class IPAddress(PrimaryModel):
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk:
|
||||
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if device:
|
||||
if getattr(self.assigned_object, 'device', None) != device:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for device {device} but not assigned to it!"
|
||||
})
|
||||
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if vm:
|
||||
if getattr(self.assigned_object, 'virtual_machine', None) != vm:
|
||||
raise ValidationError({
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
|
||||
})
|
||||
for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')):
|
||||
parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if parent and getattr(self.assigned_object, attr) != parent:
|
||||
# Check for a NAT relationship
|
||||
if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for {cls._meta.model_name} {parent} but "
|
||||
f"not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
if self.status == IPAddressStatusChoices.STATUS_SLAAC and self.family != 6:
|
||||
|
||||
@@ -65,7 +65,7 @@ VLAN_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
|
||||
{% elif perms.ipam.add_vlan %}
|
||||
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}&group={{ vlan_group.pk }}{% if vlan_group.site %}&site={{ vlan_group.site.pk }}{% endif %}" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
|
||||
<a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
|
||||
{% else %}
|
||||
{{ record.available }} VLAN{{ record.available|pluralize }} available
|
||||
{% endif %}
|
||||
|
||||
@@ -333,10 +333,10 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,description",
|
||||
"VLAN Group 4,vlan-group-4,Fourth VLAN group",
|
||||
"VLAN Group 5,vlan-group-5,Fifth VLAN group",
|
||||
"VLAN Group 6,vlan-group-6,Sixth VLAN group",
|
||||
f"name,slug,scope_type,scope_id,description",
|
||||
f"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
|
||||
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
|
||||
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
|
||||
)
|
||||
|
||||
cls.bulk_edit_data = {
|
||||
|
||||
@@ -68,24 +68,40 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
|
||||
return output
|
||||
|
||||
|
||||
def add_available_vlans(vlan_group, vlans):
|
||||
def add_available_vlans(vlans, vlan_group=None):
|
||||
"""
|
||||
Create fake records for all gaps between used VLANs
|
||||
"""
|
||||
if not vlans:
|
||||
return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
|
||||
return [{
|
||||
'vid': VLAN_VID_MIN,
|
||||
'vlan_group': vlan_group,
|
||||
'available': VLAN_VID_MAX - VLAN_VID_MIN + 1
|
||||
}]
|
||||
|
||||
prev_vid = VLAN_VID_MAX
|
||||
new_vlans = []
|
||||
for vlan in vlans:
|
||||
if vlan.vid - prev_vid > 1:
|
||||
new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
|
||||
new_vlans.append({
|
||||
'vid': prev_vid + 1,
|
||||
'vlan_group': vlan_group,
|
||||
'available': vlan.vid - prev_vid - 1,
|
||||
})
|
||||
prev_vid = vlan.vid
|
||||
|
||||
if vlans[0].vid > VLAN_VID_MIN:
|
||||
new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
|
||||
new_vlans.append({
|
||||
'vid': VLAN_VID_MIN,
|
||||
'vlan_group': vlan_group,
|
||||
'available': vlans[0].vid - VLAN_VID_MIN,
|
||||
})
|
||||
if prev_vid < VLAN_VID_MAX:
|
||||
new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
|
||||
new_vlans.append({
|
||||
'vid': prev_vid + 1,
|
||||
'vlan_group': vlan_group,
|
||||
'available': VLAN_VID_MAX - prev_vid,
|
||||
})
|
||||
|
||||
vlans = list(vlans) + new_vlans
|
||||
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
|
||||
from dcim.models import Device, Interface
|
||||
from netbox.views import generic
|
||||
from utilities.forms import TableConfigForm
|
||||
from utilities.tables import paginate_table
|
||||
from utilities.utils import count_related
|
||||
from virtualization.models import VirtualMachine, VMInterface
|
||||
@@ -145,7 +146,6 @@ class RIRListView(generic.ObjectListView):
|
||||
filterset = filtersets.RIRFilterSet
|
||||
filterset_form = forms.RIRFilterForm
|
||||
table = tables.RIRTable
|
||||
template_name = 'ipam/rir_list.html'
|
||||
|
||||
|
||||
class RIRView(generic.ObjectView):
|
||||
@@ -413,7 +413,7 @@ class PrefixPrefixesView(generic.ObjectView):
|
||||
if child_prefixes and request.GET.get('show_available', 'true') == 'true':
|
||||
child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
|
||||
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes)
|
||||
prefix_table = tables.PrefixDetailTable(child_prefixes, user=request.user)
|
||||
if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
|
||||
prefix_table.columns.show('pk')
|
||||
paginate_table(prefix_table, request)
|
||||
@@ -434,6 +434,7 @@ class PrefixPrefixesView(generic.ObjectView):
|
||||
'bulk_querystring': bulk_querystring,
|
||||
'active_tab': 'prefixes',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
'table_config_form': TableConfigForm(table=prefix_table),
|
||||
}
|
||||
|
||||
|
||||
@@ -676,7 +677,7 @@ class VLANGroupView(generic.ObjectView):
|
||||
Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
|
||||
).order_by('vid')
|
||||
vlans_count = vlans.count()
|
||||
vlans = add_available_vlans(instance, vlans)
|
||||
vlans = add_available_vlans(vlans, vlan_group=instance)
|
||||
|
||||
vlans_table = tables.VLANDetailTable(vlans)
|
||||
if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
|
||||
|
||||
@@ -25,6 +25,15 @@ class TokenAuthentication(authentication.TokenAuthentication):
|
||||
if not token.user.is_active:
|
||||
raise exceptions.AuthenticationFailed("User inactive")
|
||||
|
||||
# When LDAP authentication is active try to load user data from LDAP directory
|
||||
if settings.REMOTE_AUTH_BACKEND == 'netbox.authentication.LDAPBackend':
|
||||
from netbox.authentication import LDAPBackend
|
||||
ldap_backend = LDAPBackend()
|
||||
user = ldap_backend.populate_user(token.user.username)
|
||||
# If the user is found in the LDAP directory use it, if not fallback to the local user
|
||||
if user:
|
||||
return user, token
|
||||
|
||||
return token.user, token
|
||||
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from users.models import ObjectPermission
|
||||
from utilities.permissions import permission_is_exempt, resolve_permission, resolve_permission_ct
|
||||
|
||||
|
||||
class ObjectPermissionBackend(ModelBackend):
|
||||
class ObjectPermissionMixin():
|
||||
|
||||
def get_all_permissions(self, user_obj, obj=None):
|
||||
if not user_obj.is_active or user_obj.is_anonymous:
|
||||
@@ -20,13 +20,16 @@ class ObjectPermissionBackend(ModelBackend):
|
||||
user_obj._object_perm_cache = self.get_object_permissions(user_obj)
|
||||
return user_obj._object_perm_cache
|
||||
|
||||
def get_permission_filter(self, user_obj):
|
||||
return Q(users=user_obj) | Q(groups__user=user_obj)
|
||||
|
||||
def get_object_permissions(self, user_obj):
|
||||
"""
|
||||
Return all permissions granted to the user by an ObjectPermission.
|
||||
"""
|
||||
# Retrieve all assigned and enabled ObjectPermissions
|
||||
object_permissions = ObjectPermission.objects.filter(
|
||||
Q(users=user_obj) | Q(groups__user=user_obj),
|
||||
self.get_permission_filter(user_obj),
|
||||
enabled=True
|
||||
).prefetch_related('object_types')
|
||||
|
||||
@@ -86,6 +89,10 @@ class ObjectPermissionBackend(ModelBackend):
|
||||
return model.objects.filter(constraints, pk=obj.pk).exists()
|
||||
|
||||
|
||||
class ObjectPermissionBackend(ObjectPermissionMixin, ModelBackend):
|
||||
pass
|
||||
|
||||
|
||||
class RemoteUserBackend(_RemoteUserBackend):
|
||||
"""
|
||||
Custom implementation of Django's RemoteUserBackend which provides configuration hooks for basic customization.
|
||||
@@ -133,11 +140,27 @@ class RemoteUserBackend(_RemoteUserBackend):
|
||||
return False
|
||||
|
||||
|
||||
# Create a new instance of django-auth-ldap's LDAPBackend with our own ObjectPermissions
|
||||
try:
|
||||
from django_auth_ldap.backend import LDAPBackend as LDAPBackend_
|
||||
|
||||
class NBLDAPBackend(ObjectPermissionMixin, LDAPBackend_):
|
||||
def get_permission_filter(self, user_obj):
|
||||
permission_filter = super().get_permission_filter(user_obj)
|
||||
if (self.settings.FIND_GROUP_PERMS and
|
||||
hasattr(user_obj, "ldap_user") and
|
||||
hasattr(user_obj.ldap_user, "group_names")):
|
||||
permission_filter = permission_filter | Q(groups__name__in=user_obj.ldap_user.group_names)
|
||||
return permission_filter
|
||||
except ModuleNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
class LDAPBackend:
|
||||
|
||||
def __new__(cls, *args, **kwargs):
|
||||
try:
|
||||
from django_auth_ldap.backend import LDAPBackend as LDAPBackend_, LDAPSettings
|
||||
from django_auth_ldap.backend import LDAPSettings
|
||||
import ldap
|
||||
except ModuleNotFoundError as e:
|
||||
if getattr(e, 'name') == 'django_auth_ldap':
|
||||
@@ -163,8 +186,7 @@ class LDAPBackend:
|
||||
"Required parameter AUTH_LDAP_SERVER_URI is missing from ldap_config.py."
|
||||
)
|
||||
|
||||
# Create a new instance of django-auth-ldap's LDAPBackend
|
||||
obj = LDAPBackend_()
|
||||
obj = NBLDAPBackend()
|
||||
|
||||
# Read LDAP configuration parameters from ldap_config.py instead of settings.py
|
||||
settings = LDAPSettings()
|
||||
|
||||
@@ -69,7 +69,7 @@ SECRET_KEY = ''
|
||||
# Specify one or more name and email address tuples representing NetBox administrators. These people will be notified of
|
||||
# application errors (assuming correct email settings are provided).
|
||||
ADMINS = [
|
||||
# ['John Doe', 'jdoe@example.com'],
|
||||
# ('John Doe', 'jdoe@example.com'),
|
||||
]
|
||||
|
||||
# URL schemes that are allowed within links in NetBox
|
||||
@@ -89,8 +89,8 @@ BANNER_LOGIN = ''
|
||||
# BASE_PATH = 'netbox/'
|
||||
BASE_PATH = ''
|
||||
|
||||
# Cache timeout in seconds. Set to 0 to dissable caching. Defaults to 900 (15 minutes)
|
||||
CACHE_TIMEOUT = 900
|
||||
# Cache timeout in seconds. Defaults to zero (disabled).
|
||||
CACHE_TIMEOUT = 0
|
||||
|
||||
# Maximum number of days to retain logged changes. Set to 0 to retain changes indefinitely. (Default: 90)
|
||||
CHANGELOG_RETENTION = 90
|
||||
@@ -149,6 +149,10 @@ INTERNAL_IPS = ('127.0.0.1', '::1')
|
||||
# https://docs.djangoproject.com/en/stable/topics/logging/
|
||||
LOGGING = {}
|
||||
|
||||
# Automatically reset the lifetime of a valid session upon each authenticated request. Enables users to remain
|
||||
# authenticated to NetBox indefinitely.
|
||||
LOGIN_PERSISTENCE = False
|
||||
|
||||
# Setting this to True will permit only authenticated users to access any part of NetBox. By default, anonymous users
|
||||
# are permitted to access most data in NetBox (excluding secrets) but not make any changes.
|
||||
LOGIN_REQUIRED = False
|
||||
|
||||
@@ -4,12 +4,12 @@ from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNet
|
||||
from circuits.models import Circuit, ProviderNetwork, Provider
|
||||
from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
|
||||
from dcim.filtersets import (
|
||||
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
|
||||
SiteFilterSet, VirtualChassisFilterSet,
|
||||
CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet,
|
||||
LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
|
||||
)
|
||||
from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, Location, Site, VirtualChassis
|
||||
from dcim.models import Cable, Device, DeviceType, Location, PowerFeed, Rack, RackReservation, Site, VirtualChassis
|
||||
from dcim.tables import (
|
||||
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
|
||||
CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable,
|
||||
VirtualChassisTable,
|
||||
)
|
||||
from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
|
||||
@@ -64,6 +64,12 @@ SEARCH_TYPES = OrderedDict((
|
||||
'table': RackTable,
|
||||
'url': 'dcim:rack_list',
|
||||
}),
|
||||
('rackreservation', {
|
||||
'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
|
||||
'filterset': RackReservationFilterSet,
|
||||
'table': RackReservationTable,
|
||||
'url': 'dcim:rackreservation_list',
|
||||
}),
|
||||
('location', {
|
||||
'queryset': Location.objects.add_related_count(
|
||||
Location.objects.all(),
|
||||
|
||||
@@ -89,13 +89,13 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
filters.MultiValueNumberFilter,
|
||||
filters.MultiValueTimeFilter
|
||||
)):
|
||||
lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP
|
||||
return FILTER_NUMERIC_BASED_LOOKUP_MAP
|
||||
|
||||
elif isinstance(existing_filter, (
|
||||
filters.TreeNodeMultipleChoiceFilter,
|
||||
)):
|
||||
# TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
|
||||
lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP
|
||||
return FILTER_TREENODE_NEGATION_LOOKUP_MAP
|
||||
|
||||
elif isinstance(existing_filter, (
|
||||
django_filters.ModelChoiceFilter,
|
||||
@@ -103,7 +103,7 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
TagFilter
|
||||
)) or existing_filter.extra.get('choices'):
|
||||
# These filter types support only negation
|
||||
lookup_map = FILTER_NEGATION_LOOKUP_MAP
|
||||
return FILTER_NEGATION_LOOKUP_MAP
|
||||
|
||||
elif isinstance(existing_filter, (
|
||||
django_filters.filters.CharFilter,
|
||||
@@ -111,12 +111,9 @@ class BaseFilterSet(django_filters.FilterSet):
|
||||
filters.MultiValueCharFilter,
|
||||
filters.MultiValueMACAddressFilter
|
||||
)):
|
||||
lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP
|
||||
return FILTER_CHAR_BASED_LOOKUP_MAP
|
||||
|
||||
else:
|
||||
lookup_map = None
|
||||
|
||||
return lookup_map
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_filters(cls):
|
||||
|
||||
@@ -11,12 +11,13 @@ OBJ_TYPE_CHOICES = (
|
||||
('DCIM', (
|
||||
('site', 'Sites'),
|
||||
('rack', 'Racks'),
|
||||
('rackreservation', 'Rack reservations'),
|
||||
('location', 'Locations'),
|
||||
('devicetype', 'Device types'),
|
||||
('device', 'Devices'),
|
||||
('virtualchassis', 'Virtual Chassis'),
|
||||
('virtualchassis', 'Virtual chassis'),
|
||||
('cable', 'Cables'),
|
||||
('powerfeed', 'Power Feeds'),
|
||||
('powerfeed', 'Power feeds'),
|
||||
)),
|
||||
('IPAM', (
|
||||
('vrf', 'VRFs'),
|
||||
|
||||
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.11.7'
|
||||
VERSION = '2.11.12'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@@ -75,7 +75,7 @@ BANNER_TOP = getattr(configuration, 'BANNER_TOP', '')
|
||||
BASE_PATH = getattr(configuration, 'BASE_PATH', '')
|
||||
if BASE_PATH:
|
||||
BASE_PATH = BASE_PATH.strip('/') + '/' # Enforce trailing slash only
|
||||
CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 900)
|
||||
CACHE_TIMEOUT = getattr(configuration, 'CACHE_TIMEOUT', 0)
|
||||
CHANGELOG_RETENTION = getattr(configuration, 'CHANGELOG_RETENTION', 90)
|
||||
CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
|
||||
CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
|
||||
@@ -103,6 +103,7 @@ NAPALM_PASSWORD = getattr(configuration, 'NAPALM_PASSWORD', '')
|
||||
NAPALM_TIMEOUT = getattr(configuration, 'NAPALM_TIMEOUT', 30)
|
||||
NAPALM_USERNAME = getattr(configuration, 'NAPALM_USERNAME', '')
|
||||
PAGINATE_COUNT = getattr(configuration, 'PAGINATE_COUNT', 50)
|
||||
LOGIN_PERSISTENCE = getattr(configuration, 'LOGIN_PERSISTENCE', False)
|
||||
PLUGINS = getattr(configuration, 'PLUGINS', [])
|
||||
PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {})
|
||||
PREFER_IPV4 = getattr(configuration, 'PREFER_IPV4', False)
|
||||
@@ -251,6 +252,7 @@ CACHING_REDIS_SKIP_TLS_VERIFY = CACHING_REDIS.get('INSECURE_SKIP_TLS_VERIFY', Fa
|
||||
if LOGIN_TIMEOUT is not None:
|
||||
# Django default is 1209600 seconds (14 days)
|
||||
SESSION_COOKIE_AGE = LOGIN_TIMEOUT
|
||||
SESSION_SAVE_EVERY_REQUEST = bool(LOGIN_PERSISTENCE)
|
||||
if SESSION_FILE_PATH is not None:
|
||||
SESSION_ENGINE = 'django.contrib.sessions.backends.file'
|
||||
|
||||
@@ -417,13 +419,7 @@ else:
|
||||
'ssl': CACHING_REDIS_SSL,
|
||||
'ssl_cert_reqs': None if CACHING_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||
}
|
||||
|
||||
if not CACHE_TIMEOUT:
|
||||
CACHEOPS_ENABLED = False
|
||||
else:
|
||||
CACHEOPS_ENABLED = True
|
||||
|
||||
|
||||
CACHEOPS_ENABLED = bool(CACHE_TIMEOUT)
|
||||
CACHEOPS_DEFAULTS = {
|
||||
'timeout': CACHE_TIMEOUT
|
||||
}
|
||||
|
||||
@@ -17,10 +17,12 @@ from django.views.generic import View
|
||||
from django_tables2.export import TableExport
|
||||
|
||||
from extras.models import CustomField, ExportTemplate
|
||||
from extras.signals import clear_webhooks
|
||||
from utilities.error_handlers import handle_protectederror
|
||||
from utilities.exceptions import AbortTransaction
|
||||
from utilities.exceptions import AbortTransaction, PermissionsViolation
|
||||
from utilities.forms import (
|
||||
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields,
|
||||
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, TableConfigForm,
|
||||
restrict_form_fields,
|
||||
)
|
||||
from utilities.permissions import get_permission_for_model
|
||||
from utilities.tables import paginate_table
|
||||
@@ -290,7 +292,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
obj = form.save()
|
||||
|
||||
# Check that the new object conforms with any assigned object-level permissions
|
||||
self.queryset.get(pk=obj.pk)
|
||||
if not self.queryset.filter(pk=obj.pk).first():
|
||||
raise PermissionsViolation()
|
||||
|
||||
msg = '{} {}'.format(
|
||||
'Created' if object_created else 'Modified',
|
||||
@@ -304,24 +307,26 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
messages.success(request, mark_safe(msg))
|
||||
|
||||
if '_addanother' in request.POST:
|
||||
redirect_url = request.path
|
||||
return_url = request.GET.get('return_url')
|
||||
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
|
||||
redirect_url = f'{redirect_url}?return_url={return_url}'
|
||||
|
||||
# If the object has clone_fields, pre-populate a new instance of the form
|
||||
if hasattr(obj, 'clone_fields'):
|
||||
url = '{}?{}'.format(request.path, prepare_cloned_fields(obj))
|
||||
return redirect(url)
|
||||
redirect_url += f"{'&' if return_url else '?'}{prepare_cloned_fields(obj)}"
|
||||
|
||||
return redirect(request.get_full_path())
|
||||
return redirect(redirect_url)
|
||||
|
||||
return_url = form.cleaned_data.get('return_url')
|
||||
if return_url is not None and is_safe_url(url=return_url, allowed_hosts=request.get_host()):
|
||||
return redirect(return_url)
|
||||
else:
|
||||
return redirect(self.get_return_url(request, obj))
|
||||
return_url = self.get_return_url(request, obj)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
return redirect(return_url)
|
||||
|
||||
except PermissionsViolation:
|
||||
msg = "Object save failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -480,7 +485,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
|
||||
raise ObjectDoesNotExist
|
||||
raise PermissionsViolation
|
||||
|
||||
# If we make it to this point, validation has succeeded on all new objects.
|
||||
msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
|
||||
@@ -494,7 +499,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
except IntegrityError:
|
||||
pass
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Object creation failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
@@ -565,7 +570,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
obj = model_form.save()
|
||||
|
||||
# Enforce object-level permissions
|
||||
self.queryset.get(pk=obj.pk)
|
||||
if not self.queryset.filter(pk=obj.pk).first():
|
||||
raise PermissionsViolation()
|
||||
|
||||
logger.debug(f"Created {obj} (PK: {obj.pk})")
|
||||
|
||||
@@ -599,12 +605,13 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
raise ObjectDoesNotExist
|
||||
|
||||
except AbortTransaction:
|
||||
pass
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Object creation failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
if not model_form.errors:
|
||||
logger.info(f"Import object {obj} (PK: {obj.pk})")
|
||||
@@ -665,6 +672,22 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
from_form=self.model_form,
|
||||
widget=Textarea(attrs=self.widget_attrs)
|
||||
)
|
||||
csv_file = CSVFileField(
|
||||
label="CSV file",
|
||||
from_form=self.model_form,
|
||||
required=False
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
csv_rows = self.cleaned_data['csv'][1] if 'csv' in self.cleaned_data else None
|
||||
csv_file = self.files.get('csv_file')
|
||||
|
||||
# Check that the user has not submitted both text data and a file
|
||||
if csv_rows and csv_file:
|
||||
raise ValidationError(
|
||||
"Cannot process CSV text and file attachment simultaneously. Please choose only one import "
|
||||
"method."
|
||||
)
|
||||
|
||||
return ImportForm(*args, **kwargs)
|
||||
|
||||
@@ -689,7 +712,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
def post(self, request):
|
||||
logger = logging.getLogger('netbox.views.BulkImportView')
|
||||
new_objs = []
|
||||
form = self._import_form(request.POST)
|
||||
form = self._import_form(request.POST, request.FILES)
|
||||
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
@@ -697,7 +720,10 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
try:
|
||||
# Iterate through CSV data and bind each row to a new model form instance.
|
||||
with transaction.atomic():
|
||||
headers, records = form.cleaned_data['csv']
|
||||
if request.FILES:
|
||||
headers, records = form.cleaned_data['csv_file']
|
||||
else:
|
||||
headers, records = form.cleaned_data['csv']
|
||||
for row, data in enumerate(records, start=1):
|
||||
obj_form = self.model_form(data, headers=headers)
|
||||
restrict_form_fields(obj_form, request.user)
|
||||
@@ -712,7 +738,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
|
||||
raise ObjectDoesNotExist
|
||||
raise PermissionsViolation
|
||||
|
||||
# Compile a table containing the imported objects
|
||||
obj_table = self.table(new_objs)
|
||||
@@ -728,12 +754,13 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
})
|
||||
|
||||
except ValidationError:
|
||||
pass
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Object import failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -845,7 +872,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
|
||||
raise ObjectDoesNotExist
|
||||
raise PermissionsViolation
|
||||
|
||||
if updated_objects:
|
||||
msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
|
||||
@@ -856,11 +883,13 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
except ValidationError as e:
|
||||
messages.error(self.request, "{} failed validation: {}".format(obj, e))
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Object update failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
else:
|
||||
logger.debug("Form validation failed")
|
||||
@@ -952,7 +981,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# Enforce constrained permissions
|
||||
if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
|
||||
raise ObjectDoesNotExist
|
||||
raise PermissionsViolation
|
||||
|
||||
messages.success(request, "Renamed {} {}".format(
|
||||
len(selected_objects),
|
||||
@@ -960,10 +989,11 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
))
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Object update failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
else:
|
||||
form = self.form(initial={'pk': request.POST.getlist('pk')})
|
||||
@@ -1146,7 +1176,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
|
||||
raise ObjectDoesNotExist
|
||||
raise PermissionsViolation
|
||||
|
||||
messages.success(request, "Added {} {}".format(
|
||||
len(new_components), self.queryset.model._meta.verbose_name_plural
|
||||
@@ -1156,10 +1186,11 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
|
||||
else:
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Component creation failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'component_type': self.queryset.model._meta.verbose_name,
|
||||
@@ -1229,7 +1260,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
|
||||
component_form = self.model_form(component_data)
|
||||
if component_form.is_valid():
|
||||
instance = component_form.save()
|
||||
logger.debug(f"Created {instance} on {instance.parent}")
|
||||
logger.debug(f"Created {instance} on {instance.parent_object}")
|
||||
new_components.append(instance)
|
||||
else:
|
||||
for field, errors in component_form.errors.as_data().items():
|
||||
@@ -1238,15 +1269,16 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
|
||||
|
||||
# Enforce object-level permissions
|
||||
if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
|
||||
raise ObjectDoesNotExist
|
||||
raise PermissionsViolation
|
||||
|
||||
except IntegrityError:
|
||||
pass
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
except ObjectDoesNotExist:
|
||||
except PermissionsViolation:
|
||||
msg = "Component creation failed due to object-level permissions violation"
|
||||
logger.debug(msg)
|
||||
form.add_error(None, msg)
|
||||
clear_webhooks.send(sender=self)
|
||||
|
||||
if not form.errors:
|
||||
msg = "Added {} {} to {} {}.".format(
|
||||
|
||||
@@ -337,22 +337,26 @@ $(document).ready(function() {
|
||||
$('select#id_untagged_vlan').trigger('change');
|
||||
$('select#id_tagged_vlans').val([]);
|
||||
$('select#id_tagged_vlans').trigger('change');
|
||||
$('select#id_vlan_group').parent().parent().hide();
|
||||
$('select#id_untagged_vlan').parent().parent().hide();
|
||||
$('select#id_tagged_vlans').parent().parent().hide();
|
||||
}
|
||||
else if ($(this).val() == 'access') {
|
||||
$('select#id_tagged_vlans').val([]);
|
||||
$('select#id_tagged_vlans').trigger('change');
|
||||
$('select#id_vlan_group').parent().parent().show();
|
||||
$('select#id_untagged_vlan').parent().parent().show();
|
||||
$('select#id_tagged_vlans').parent().parent().hide();
|
||||
}
|
||||
else if ($(this).val() == 'tagged') {
|
||||
$('select#id_vlan_group').parent().parent().show();
|
||||
$('select#id_untagged_vlan').parent().parent().show();
|
||||
$('select#id_tagged_vlans').parent().parent().show();
|
||||
}
|
||||
else if ($(this).val() == 'tagged-all') {
|
||||
$('select#id_tagged_vlans').val([]);
|
||||
$('select#id_tagged_vlans').trigger('change');
|
||||
$('select#id_vlan_group').parent().parent().show();
|
||||
$('select#id_untagged_vlan').parent().parent().show();
|
||||
$('select#id_tagged_vlans').parent().parent().hide();
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
<p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
|
||||
<p class="text-muted">{% annotated_now %} {% now 'T' %}</p>
|
||||
</div>
|
||||
<div class="col-xs-4 text-right noprint">
|
||||
<p class="text-muted">
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Install Date</td>
|
||||
<td>{{ object.install_date|placeholder }}</td>
|
||||
<td>{{ object.install_date|annotated_date|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Commit Rate</td>
|
||||
|
||||
@@ -39,9 +39,9 @@ $(document).ready(function() {
|
||||
url: "{% url 'dcim-api:device-napalm' pk=object.pk %}?method=get_config",
|
||||
dataType: 'json',
|
||||
success: function(json) {
|
||||
$('#running_config').html($.trim(json['get_config']['running']));
|
||||
$('#startup_config').html($.trim(json['get_config']['startup']));
|
||||
$('#candidate_config').html($.trim(json['get_config']['candidate']));
|
||||
$('#running_config').text($.trim(json['get_config']['running']));
|
||||
$('#startup_config').text($.trim(json['get_config']['startup']));
|
||||
$('#candidate_config').text($.trim(json['get_config']['candidate']));
|
||||
},
|
||||
error: function(xhr) {
|
||||
alert(xhr.responseText);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{% extends 'dcim/device/base.html' %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}{{ device }} - Status{% endblock %}
|
||||
{% block title %}{{ object }} - Status{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% include 'inc/ajax_loader.html' %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Create {{ component_type }}{% endblock %}
|
||||
@@ -18,19 +19,34 @@
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ component_type|title }}</strong>
|
||||
<strong>{{ component_type|bettertitle }}</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if not form.custom_fields or field.name not in form.custom_fields %}
|
||||
{% render_field field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
{% if form.custom_fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading"><strong>Custom Fields</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block bulk_buttons %}
|
||||
{% if perms.dcim.change_device %}
|
||||
<div class="btn-group">
|
||||
<div class="btn-group dropup">
|
||||
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Components <span class="caret"></span>
|
||||
</button>
|
||||
|
||||
@@ -42,7 +42,17 @@
|
||||
<tr>
|
||||
<td>Devices</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
|
||||
<a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ device_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Virtual Machines</td>
|
||||
<td>
|
||||
{% if object.vm_role %}
|
||||
<a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -54,6 +54,16 @@
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Management Only</td>
|
||||
<td>
|
||||
{% if object.mgmt_only %}
|
||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||
{% else %}
|
||||
<span class="text-danger"><i class="mdi mdi-close"></i></span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Parent</td>
|
||||
<td>
|
||||
@@ -88,7 +98,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>802.1Q Mode</td>
|
||||
<td>{{ object.get_mode_display }}</td>
|
||||
<td>{{ object.get_mode_display|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.vlan_group %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
|
||||
@@ -29,6 +29,12 @@
|
||||
<a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.pk }}">{{ devicetypes_table.rows|length }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Inventory Items</td>
|
||||
<td>
|
||||
<a href="{% url 'dcim:inventoryitem_list' %}?manufacturer_id={{ object.pk }}">{{ inventory_item_count }}</a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
{% plugin_left_page object %}
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
{% block breadcrumbs %}
|
||||
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack_list' %}?site_id={{ object.site.pk }}">{{ object.site }}</a></li>
|
||||
{% if object.group %}
|
||||
{% for group in object.group.get_ancestors %}
|
||||
<li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
|
||||
{% if object.location %}
|
||||
{% for location in object.location.get_ancestors %}
|
||||
<li><a href="{{ location.get_absolute_url }}">{{ location }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a href="{{ object.group.get_absolute_url }}">{{ object.group }}</a></li>
|
||||
<li><a href="{{ object.location.get_absolute_url }}">{{ object.location }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ object }}</li>
|
||||
{% endblock %}
|
||||
@@ -268,7 +268,7 @@
|
||||
</td>
|
||||
<td>
|
||||
{{ resv.description }}<br />
|
||||
<small>{{ resv.user }} · {{ resv.created }}</small>
|
||||
<small>{{ resv.user }} · {{ resv.created|annotated_date }}</small>
|
||||
</td>
|
||||
<td class="text-right noprint">
|
||||
{% if perms.dcim.change_rackreservation %}
|
||||
|
||||
@@ -39,10 +39,10 @@
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Group</td>
|
||||
<td>Location</td>
|
||||
<td>
|
||||
{% if rack.group %}
|
||||
<a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
|
||||
{% if rack.location %}
|
||||
<a href="{{ rack.location.get_absolute_url }}">{{ rack.location }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -80,7 +80,11 @@
|
||||
<td>
|
||||
{% if object.time_zone %}
|
||||
{{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
|
||||
<small class="text-muted">Site time: {% timezone object.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %}</small>
|
||||
<small class="text-muted">Site time:
|
||||
{% timezone object.time_zone %}
|
||||
{% annotated_now %}
|
||||
{% endtimezone %}
|
||||
</small>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
<tr>
|
||||
<td>Created</td>
|
||||
<td>
|
||||
{{ object.created }}
|
||||
{{ object.created|annotated_date }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
<tr>
|
||||
<td>Time</td>
|
||||
<td>
|
||||
{{ object.time }}
|
||||
{{ object.time|annotated_date }}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
|
||||
@@ -38,7 +38,7 @@
|
||||
<div class="col-md-12">
|
||||
{% if report.result %}
|
||||
Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
|
||||
<strong>{{ report.result.created }}</strong>
|
||||
<strong>{{ report.result.created|annotated_date }}</strong>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
<td class="rendered-markdown">{{ report.description|render_markdown|placeholder }}</td>
|
||||
<td class="text-right">
|
||||
{% if report.result %}
|
||||
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created }}</a>
|
||||
<a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">Never</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>
|
||||
Run: <strong>{{ result.created }}</strong>
|
||||
Run: <strong>{{ result.created|annotated_date }}</strong>
|
||||
{% if result.completed %}
|
||||
Duration: <strong>{{ result.duration }}</strong>
|
||||
{% else %}
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
<td>{{ script.Meta.description|render_markdown }}</td>
|
||||
{% if script.result %}
|
||||
<td class="text-right">
|
||||
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created }}</a>
|
||||
<a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-right text-muted">Never</td>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
<li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
|
||||
<li><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
|
||||
<li><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
|
||||
<li>{{ result.created }}</li>
|
||||
<li>{{ result.created|annotated_date }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
@@ -32,7 +32,7 @@
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<p>
|
||||
Run: <strong>{{ result.created }}</strong>
|
||||
Run: <strong>{{ result.created|annotated_date }}</strong>
|
||||
{% if result.completed %}
|
||||
Duration: <strong>{{ result.duration }}</strong>
|
||||
{% else %}
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
<h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
|
||||
<p>
|
||||
<small class="text-muted">
|
||||
Created {{ object.created }} ·
|
||||
Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago
|
||||
Created {{ object.created|annotated_date }} ·
|
||||
Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago)
|
||||
</small>
|
||||
<span class="label label-default">{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}</span>
|
||||
</p>
|
||||
|
||||
@@ -16,103 +16,107 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<ul class="nav nav-tabs" role="tablist">
|
||||
<li role="presentation" class="active"><a href="#csv" role="tab" data-toggle="tab">CSV</a></li>
|
||||
<li role="presentation" class="active"><a href="#csv" role="tab" data-toggle="tab">CSV Data</a></li>
|
||||
<li role="presentation"><a href="#csv-file" role="tab" data-toggle="tab">CSV File Upload</a></li>
|
||||
</ul>
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="csv">
|
||||
<form action="" method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{% render_form form %}
|
||||
<div class="form-group">
|
||||
<div class="col-md-12 text-right">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="clearfix"></div>
|
||||
<p></p>
|
||||
{% if fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>CSV Field Options</strong>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Required</th>
|
||||
<th>Accessor</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
{% for name, field in fields.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{% if field.required %}
|
||||
<i class="mdi mdi-check-bold text-success" title="Required"></i>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field.to_field_name %}
|
||||
<code>{{ field.to_field_name }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field.STATIC_CHOICES %}
|
||||
<button type="button" class="btn btn-link btn-xs pull-right" data-toggle="modal" data-target="#{{ name }}_choices">
|
||||
<i class="mdi mdi-help-circle"></i>
|
||||
</button>
|
||||
<div class="modal fade" id="{{ name }}_choices" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title"><code>{{ name }}</code> Choices</h4>
|
||||
</div>
|
||||
<table class="table table-striped modal-body">
|
||||
<tr><th>Import Value</th><th>Label</th></tr>
|
||||
{% for value, label in field.choices %}
|
||||
{% if value %}<tr><td><samp>{{ value }}</samp></td><td>{{ label }}</td></tr>{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
{{ field.help_text }}<br />
|
||||
{% elif field.label %}
|
||||
{{ field.label }}<br />
|
||||
{% endif %}
|
||||
{% if field|widget_type == 'dateinput' %}
|
||||
<small class="text-muted">Format: YYYY-MM-DD</small>
|
||||
{% elif field|widget_type == 'checkboxinput' %}
|
||||
<small class="text-muted">Specify "true" or "false"</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<p class="small text-muted">
|
||||
<i class="mdi mdi-check-bold"></i> Required fields <strong>must</strong> be specified for all
|
||||
objects.
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
<i class="mdi mdi-information-outline"></i> Related objects may be referenced by any unique attribute.
|
||||
For example, <code>vrf.rd</code> would identify a VRF by its route distinguisher.
|
||||
</p>
|
||||
{% endif %}
|
||||
<form action="" method="post" class="form" enctype="multipart/form-data">
|
||||
{% csrf_token %}
|
||||
<div class="tab-content">
|
||||
<div role="tabpanel" class="tab-pane active" id="csv">
|
||||
{% render_field form.csv %}
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane" id="csv-file">
|
||||
{% render_field form.csv_file %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-12 text-right">
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
{% if return_url %}
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="clearfix"></div>
|
||||
<p></p>
|
||||
{% if fields %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>CSV Field Options</strong>
|
||||
</div>
|
||||
<table class="table">
|
||||
<tr>
|
||||
<th>Field</th>
|
||||
<th>Required</th>
|
||||
<th>Accessor</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
{% for name, field in fields.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<code>{{ name }}</code>
|
||||
</td>
|
||||
<td>
|
||||
{% if field.required %}
|
||||
<i class="mdi mdi-check-bold text-success" title="Required"></i>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field.to_field_name %}
|
||||
<code>{{ field.to_field_name }}</code>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if field.STATIC_CHOICES %}
|
||||
<button type="button" class="btn btn-link btn-xs pull-right" data-toggle="modal" data-target="#{{ name }}_choices">
|
||||
<i class="mdi mdi-help-circle"></i>
|
||||
</button>
|
||||
<div class="modal fade" id="{{ name }}_choices" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog" role="document">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<button type="button" class="close" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button>
|
||||
<h4 class="modal-title"><code>{{ name }}</code> Choices</h4>
|
||||
</div>
|
||||
<table class="table table-striped modal-body">
|
||||
<tr><th>Import Value</th><th>Label</th></tr>
|
||||
{% for value, label in field.choices %}
|
||||
{% if value %}<tr><td><samp>{{ value }}</samp></td><td>{{ label }}</td></tr>{% endif %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if field.help_text %}
|
||||
{{ field.help_text }}<br />
|
||||
{% elif field.label %}
|
||||
{{ field.label }}<br />
|
||||
{% endif %}
|
||||
{% if field|widget_type == 'dateinput' %}
|
||||
<small class="text-muted">Format: YYYY-MM-DD</small>
|
||||
{% elif field|widget_type == 'checkboxinput' %}
|
||||
<small class="text-muted">Specify "true" or "false"</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
<p class="small text-muted">
|
||||
<i class="mdi mdi-check-bold"></i> Required fields <strong>must</strong> be specified for all
|
||||
objects.
|
||||
</p>
|
||||
<p class="small text-muted">
|
||||
<i class="mdi mdi-information-outline"></i> Related objects may be referenced by any unique attribute.
|
||||
For example, <code>vrf.rd</code> would identify a VRF by its route distinguisher.
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -291,7 +291,7 @@
|
||||
{% for result in report_results %}
|
||||
<tr>
|
||||
<td><a href="{% url 'extras:report_result' job_result_pk=result.pk %}">{{ result.name }}</a></td>
|
||||
<td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/job_label.html' %}</span></td>
|
||||
<td class="text-right"><span title="{{ result.created|date:'SHORT_DATETIME_FORMAT' }}">{% include 'extras/inc/job_label.html' %}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% load helpers %}
|
||||
{% if images %}
|
||||
<table class="table table-hover panel-body">
|
||||
<tr>
|
||||
@@ -13,7 +14,7 @@
|
||||
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
</td>
|
||||
<td>{{ attachment.size|filesizeformat }}</td>
|
||||
<td>{{ attachment.created }}</td>
|
||||
<td>{{ attachment.created|annotated_date }}</td>
|
||||
<td class="text-right noprint">
|
||||
{% if perms.extras.change_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-xs" title="Edit image">
|
||||
|
||||
@@ -54,7 +54,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Date Added</td>
|
||||
<td>{{ object.date_added|placeholder }}</td>
|
||||
<td>{{ object.date_added|annotated_date|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Description</td>
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
<td>Role</td>
|
||||
<td>
|
||||
{% if object.role %}
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
|
||||
<a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}" class="label label-{{ object.get_role_class }}">{{ object.get_role_display }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
{% extends 'ipam/prefix/base.html' %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
|
||||
{% block buttons %}
|
||||
{% include 'ipam/inc/toggle_available.html' %}
|
||||
{% if request.user.is_authenticated and table_config_form %}
|
||||
<button type="button" class="btn btn-default" data-toggle="modal" data-target="#PrefixDetailTable_config" title="Configure table"><i class="mdi mdi-cog"></i> Configure</button>
|
||||
{% endif %}
|
||||
{% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %}
|
||||
<a href="{% url 'ipam:prefix_add' %}?prefix={{ first_available_prefix }}&vrf={{ object.vrf.pk }}&site={{ object.site.pk }}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}" class="btn btn-success">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Child Prefix
|
||||
@@ -22,4 +27,9 @@
|
||||
{% include 'utilities/obj_table.html' with table=prefix_table table_template='panel_table.html' heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
|
||||
</div>
|
||||
</div>
|
||||
{% table_config_form prefix_table table_name="PrefixDetailTable" %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascript %}
|
||||
<script src="{% static 'js/tableconfig.js' %}"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
{% extends 'generic/object_list.html' %}
|
||||
|
||||
{% block buttons %}
|
||||
{% if request.GET.family == '6' %}
|
||||
<a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
|
||||
<span class="mdi mdi-table" aria-hidden="true"></span>
|
||||
IPv4 Stats
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{% url 'ipam:rir_list' %}?family=6{% if request.GET %}&{{ request.GET.urlencode }}{% endif %}" class="btn btn-default">
|
||||
<span class="mdi mdi-table" aria-hidden="true"></span>
|
||||
IPv6 Stats
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
{% block sidebar %}
|
||||
{% if request.GET.family == '6' %}
|
||||
<div class="alert alert-info">
|
||||
<i class="mdi mdi-information-outline"></i> Numbers shown indicate /64 prefixes.
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -24,12 +24,12 @@
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Created</small><br />
|
||||
<span title="{{ token.created }}">{{ token.created|date }}</span>
|
||||
{{ token.created|annotated_date }}
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<small class="text-muted">Expires</small><br />
|
||||
{% if token.expires %}
|
||||
<span title="{{ token.expires }}">{{ token.expires|date }}</span>
|
||||
{{ token.expires|annotated_date }}
|
||||
{% else %}
|
||||
<span>Never</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<small class="text-muted">Email</small>
|
||||
<h5>{{ request.user.email }}</h5>
|
||||
<small class="text-muted">Registered</small>
|
||||
<h5>{{ request.user.date_joined }}</h5>
|
||||
<h5>{{ request.user.date_joined|annotated_date }}</h5>
|
||||
<small class="text-muted">Groups</small>
|
||||
<h5>{{ request.user.groups.all|join:', ' }}</h5>
|
||||
<small class="text-muted">Admin access</small>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'users/base.html' %}
|
||||
{% load helpers %}
|
||||
|
||||
{% block title %}User Key{% endblock %}
|
||||
|
||||
@@ -19,7 +20,9 @@
|
||||
{% endif %}
|
||||
</h4>
|
||||
<p>
|
||||
<small class="text-muted">Created {{ object.created }} · Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
|
||||
<small class="text-muted">
|
||||
Created {{ object.created|annotated_date }} ·
|
||||
Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago)
|
||||
</p>
|
||||
{% if not object.is_active %}
|
||||
<div class="alert alert-warning" role="alert">
|
||||
@@ -37,7 +40,7 @@
|
||||
</a>
|
||||
</div>
|
||||
<h4>Session key: <span class="label label-success">Active</span></h4>
|
||||
<small class="text-muted">Created {{ object.session_key.created }}</small>
|
||||
<small class="text-muted">Created {{ object.session_key.created|annotated_date }}</small>
|
||||
{% else %}
|
||||
<h4>No active session key</h4>
|
||||
{% endif %}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
<td><i class="mdi mdi-chip"></i> Memory</td>
|
||||
<td>
|
||||
{% if object.memory %}
|
||||
{{ object.memory }} MB
|
||||
<span title="{{ object.memory }} MB">{{ object.memory|humanize_megabytes }}</span>
|
||||
{% else %}
|
||||
<span class="text-muted">—</span>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
{% extends 'base.html' %}
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
|
||||
{% block title %}Create {{ component_type }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" class="form form-horizontal">
|
||||
{% csrf_token %}
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
{% if form.non_field_errors %}
|
||||
<div class="panel panel-danger">
|
||||
<div class="panel-heading"><strong>Errors</strong></div>
|
||||
<div class="panel-body">
|
||||
{{ form.non_field_errors }}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">
|
||||
<strong>{{ component_type|bettertitle }}</strong>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
{% render_form form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<div class="col-md-9 col-md-offset-3">
|
||||
<button type="submit" name="_create" class="btn btn-primary">Create</button>
|
||||
<button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
|
||||
<a href="{{ return_url }}" class="btn btn-default">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -28,6 +28,7 @@
|
||||
<div class="panel-heading"><strong>802.1Q Switching</strong></div>
|
||||
<div class="panel-body">
|
||||
{% render_field form.mode %}
|
||||
{% render_field form.vlan_group %}
|
||||
{% render_field form.untagged_vlan %}
|
||||
{% render_field form.tagged_vlans %}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
|
||||
isw='istartswith',
|
||||
nisw='istartswith',
|
||||
ie='iexact',
|
||||
nie='iexact'
|
||||
nie='iexact',
|
||||
empty='empty',
|
||||
)
|
||||
|
||||
FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
|
||||
|
||||
@@ -9,6 +9,14 @@ class AbortTransaction(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PermissionsViolation(Exception):
|
||||
"""
|
||||
Raised when an operation was prevented because it would violate the
|
||||
allowed permissions.
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
class RQWorkerNotRunningException(APIException):
|
||||
"""
|
||||
Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker
|
||||
|
||||
@@ -17,7 +17,7 @@ from utilities.utils import content_type_name
|
||||
from utilities.validators import EnhancedURLValidator
|
||||
from . import widgets
|
||||
from .constants import *
|
||||
from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern
|
||||
from .utils import expand_alphanumeric_pattern, expand_ipaddress_pattern, parse_csv, validate_csv
|
||||
|
||||
__all__ = (
|
||||
'CommentField',
|
||||
@@ -26,6 +26,7 @@ __all__ = (
|
||||
'CSVChoiceField',
|
||||
'CSVContentTypeField',
|
||||
'CSVDataField',
|
||||
'CSVFileField',
|
||||
'CSVModelChoiceField',
|
||||
'CSVTypedChoiceField',
|
||||
'DynamicModelChoiceField',
|
||||
@@ -174,49 +175,54 @@ class CSVDataField(forms.CharField):
|
||||
'in double quotes.'
|
||||
|
||||
def to_python(self, value):
|
||||
|
||||
records = []
|
||||
reader = csv.reader(StringIO(value.strip()))
|
||||
|
||||
# Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
|
||||
# "to" field specifying how the related object is being referenced. For example, importing a Device might use a
|
||||
# `site.slug` header, to indicate the related site is being referenced by its slug.
|
||||
headers = {}
|
||||
for header in next(reader):
|
||||
if '.' in header:
|
||||
field, to_field = header.split('.', 1)
|
||||
headers[field] = to_field
|
||||
else:
|
||||
headers[header] = None
|
||||
return parse_csv(reader)
|
||||
|
||||
# Parse CSV rows into a list of dictionaries mapped from the column headers.
|
||||
for i, row in enumerate(reader, start=1):
|
||||
if len(row) != len(headers):
|
||||
raise forms.ValidationError(
|
||||
f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
|
||||
)
|
||||
row = [col.strip() for col in row]
|
||||
record = dict(zip(headers.keys(), row))
|
||||
records.append(record)
|
||||
def validate(self, value):
|
||||
headers, records = value
|
||||
validate_csv(headers, self.fields, self.required_fields)
|
||||
|
||||
return value
|
||||
|
||||
|
||||
class CSVFileField(forms.FileField):
|
||||
"""
|
||||
A FileField (rendered as a file input button) which accepts a file containing CSV-formatted data. It returns
|
||||
data as a two-tuple: The first item is a dictionary of column headers, mapping field names to the attribute
|
||||
by which they match a related object (where applicable). The second item is a list of dictionaries, each
|
||||
representing a discrete row of CSV data.
|
||||
|
||||
:param from_form: The form from which the field derives its validation rules.
|
||||
"""
|
||||
|
||||
def __init__(self, from_form, *args, **kwargs):
|
||||
|
||||
form = from_form()
|
||||
self.model = form.Meta.model
|
||||
self.fields = form.fields
|
||||
self.required_fields = [
|
||||
name for name, field in form.fields.items() if field.required
|
||||
]
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def to_python(self, file):
|
||||
if file is None:
|
||||
return None
|
||||
|
||||
csv_str = file.read().decode('utf-8').strip()
|
||||
reader = csv.reader(csv_str.splitlines())
|
||||
headers, records = parse_csv(reader)
|
||||
|
||||
return headers, records
|
||||
|
||||
def validate(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
headers, records = value
|
||||
|
||||
# Validate provided column headers
|
||||
for field, to_field in headers.items():
|
||||
if field not in self.fields:
|
||||
raise forms.ValidationError(f'Unexpected column header "{field}" found.')
|
||||
if to_field and not hasattr(self.fields[field], 'to_field_name'):
|
||||
raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
|
||||
if to_field and not hasattr(self.fields[field].queryset.model, to_field):
|
||||
raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
|
||||
|
||||
# Validate required fields
|
||||
for f in self.required_fields:
|
||||
if f not in headers:
|
||||
raise forms.ValidationError(f'Required column header "{f}" not found.')
|
||||
validate_csv(headers, self.fields, self.required_fields)
|
||||
|
||||
return value
|
||||
|
||||
@@ -263,6 +269,8 @@ class CSVContentTypeField(CSVModelChoiceField):
|
||||
return f'{value.app_label}.{value.model}'
|
||||
|
||||
def to_python(self, value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
app_label, model = value.split('.')
|
||||
except ValueError:
|
||||
|
||||
@@ -14,6 +14,8 @@ __all__ = (
|
||||
'parse_alphanumeric_range',
|
||||
'parse_numeric_range',
|
||||
'restrict_form_fields',
|
||||
'parse_csv',
|
||||
'validate_csv',
|
||||
)
|
||||
|
||||
|
||||
@@ -30,7 +32,10 @@ def parse_numeric_range(string, base=10):
|
||||
begin, end = dash_range.split('-')
|
||||
except ValueError:
|
||||
begin, end = dash_range, dash_range
|
||||
begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
|
||||
try:
|
||||
begin, end = int(begin.strip(), base=base), int(end.strip(), base=base) + 1
|
||||
except ValueError:
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
values.extend(range(begin, end))
|
||||
return list(set(values))
|
||||
|
||||
@@ -62,7 +67,7 @@ def parse_alphanumeric_range(string):
|
||||
else:
|
||||
# Not a valid range (more than a single character)
|
||||
if not len(begin) == len(end) == 1:
|
||||
raise forms.ValidationError('Range "{}" is invalid.'.format(dash_range))
|
||||
raise forms.ValidationError(f'Range "{dash_range}" is invalid.')
|
||||
for n in list(range(ord(begin), ord(end) + 1)):
|
||||
values.append(chr(n))
|
||||
return values
|
||||
@@ -134,3 +139,55 @@ def restrict_form_fields(form, user, action='view'):
|
||||
for field in form.fields.values():
|
||||
if hasattr(field, 'queryset') and issubclass(field.queryset.__class__, RestrictedQuerySet):
|
||||
field.queryset = field.queryset.restrict(user, action)
|
||||
|
||||
|
||||
def parse_csv(reader):
|
||||
"""
|
||||
Parse a csv_reader object into a headers dictionary and a list of records dictionaries. Raise an error
|
||||
if the records are formatted incorrectly. Return headers and records as a tuple.
|
||||
"""
|
||||
records = []
|
||||
headers = {}
|
||||
|
||||
# Consume the first line of CSV data as column headers. Create a dictionary mapping each header to an optional
|
||||
# "to" field specifying how the related object is being referenced. For example, importing a Device might use a
|
||||
# `site.slug` header, to indicate the related site is being referenced by its slug.
|
||||
|
||||
for header in next(reader):
|
||||
if '.' in header:
|
||||
field, to_field = header.split('.', 1)
|
||||
headers[field] = to_field
|
||||
else:
|
||||
headers[header] = None
|
||||
|
||||
# Parse CSV rows into a list of dictionaries mapped from the column headers.
|
||||
for i, row in enumerate(reader, start=1):
|
||||
if len(row) != len(headers):
|
||||
raise forms.ValidationError(
|
||||
f"Row {i}: Expected {len(headers)} columns but found {len(row)}"
|
||||
)
|
||||
row = [col.strip() for col in row]
|
||||
record = dict(zip(headers.keys(), row))
|
||||
records.append(record)
|
||||
|
||||
return headers, records
|
||||
|
||||
|
||||
def validate_csv(headers, fields, required_fields):
|
||||
"""
|
||||
Validate that parsed csv data conforms to the object's available fields. Raise validation errors
|
||||
if parsed csv data contains invalid headers or does not contain required headers.
|
||||
"""
|
||||
# Validate provided column headers
|
||||
for field, to_field in headers.items():
|
||||
if field not in fields:
|
||||
raise forms.ValidationError(f'Unexpected column header "{field}" found.')
|
||||
if to_field and not hasattr(fields[field], 'to_field_name'):
|
||||
raise forms.ValidationError(f'Column "{field}" is not a related object; cannot use dots')
|
||||
if to_field and not hasattr(fields[field].queryset.model, to_field):
|
||||
raise forms.ValidationError(f'Invalid related object attribute for column "{field}": {to_field}')
|
||||
|
||||
# Validate required fields
|
||||
for f in required_fields:
|
||||
if f not in headers:
|
||||
raise forms.ValidationError(f'Required column header "{f}" not found.')
|
||||
|
||||
@@ -5,7 +5,9 @@ import re
|
||||
import yaml
|
||||
from django import template
|
||||
from django.conf import settings
|
||||
from django.template.defaultfilters import date
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.safestring import mark_safe
|
||||
from markdown import markdown
|
||||
@@ -129,6 +131,20 @@ def humanize_speed(speed):
|
||||
return '{} Kbps'.format(speed)
|
||||
|
||||
|
||||
@register.filter()
|
||||
def humanize_megabytes(mb):
|
||||
"""
|
||||
Express a number of megabytes in the most suitable unit (e.g. gigabytes or terabytes).
|
||||
"""
|
||||
if not mb:
|
||||
return ''
|
||||
if mb >= 1048576:
|
||||
return f'{int(mb / 1048576)} TB'
|
||||
if mb >= 1024:
|
||||
return f'{int(mb / 1024)} GB'
|
||||
return f'{mb} MB'
|
||||
|
||||
|
||||
@register.filter()
|
||||
def tzoffset(value):
|
||||
"""
|
||||
@@ -137,6 +153,36 @@ def tzoffset(value):
|
||||
return datetime.datetime.now(value).strftime('%z')
|
||||
|
||||
|
||||
@register.filter(expects_localtime=True)
|
||||
def annotated_date(date_value):
|
||||
"""
|
||||
Returns date as HTML span with short date format as the content and the
|
||||
(long) date format as the title.
|
||||
"""
|
||||
if not date_value:
|
||||
return ''
|
||||
|
||||
if type(date_value) == datetime.date:
|
||||
long_ts = date(date_value, 'DATE_FORMAT')
|
||||
short_ts = date(date_value, 'SHORT_DATE_FORMAT')
|
||||
else:
|
||||
long_ts = date(date_value, 'DATETIME_FORMAT')
|
||||
short_ts = date(date_value, 'SHORT_DATETIME_FORMAT')
|
||||
|
||||
span = f'<span title="{long_ts}">{short_ts}</span>'
|
||||
|
||||
return mark_safe(span)
|
||||
|
||||
|
||||
@register.simple_tag
|
||||
def annotated_now():
|
||||
"""
|
||||
Returns the current date piped through the annotated_date filter.
|
||||
"""
|
||||
tzinfo = timezone.get_current_timezone() if settings.USE_TZ else None
|
||||
return annotated_date(datetime.datetime.now(tz=tzinfo))
|
||||
|
||||
|
||||
@register.filter()
|
||||
def fgcolor(value):
|
||||
"""
|
||||
|
||||
@@ -6,7 +6,7 @@ from itertools import count, groupby
|
||||
from django.core.serializers import serialize
|
||||
from django.db.models import Count, OuterRef, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from jinja2 import Environment
|
||||
from jinja2.sandbox import SandboxedEnvironment
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from dcim.choices import CableLengthUnitChoices
|
||||
@@ -213,7 +213,7 @@ def render_jinja2(template_code, context):
|
||||
"""
|
||||
Render a Jinja2 template with the provided context. Return the rendered content.
|
||||
"""
|
||||
return Environment().from_string(source=template_code).render(**context)
|
||||
return SandboxedEnvironment().from_string(source=template_code).render(**context)
|
||||
|
||||
|
||||
def prepare_cloned_fields(instance):
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from dcim.models import Interface
|
||||
from netbox.api import WritableNestedSerializer
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
|
||||
|
||||
__all__ = [
|
||||
'NestedClusterGroupSerializer',
|
||||
@@ -61,5 +60,5 @@ class NestedVMInterfaceSerializer(WritableNestedSerializer):
|
||||
virtual_machine = NestedVirtualMachineSerializer(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = Interface
|
||||
model = VMInterface
|
||||
fields = ['id', 'url', 'display', 'virtual_machine', 'name']
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user