Compare commits

...

36 Commits

Author SHA1 Message Date
Jeremy Stretch
15ed575207 Merge pull request #6830 from netbox-community/develop
Release v2.11.10
2021-07-28 15:56:23 -04:00
jeremystretch
eae4502708 Release v2.11.10 2021-07-28 15:17:45 -04:00
jeremystretch
78ebf04be0 Shrink NetBox logo on docs main page 2021-07-28 15:12:17 -04:00
jeremystretch
49a596073e Tweak GitHub repo icon & name in docs 2021-07-28 15:07:46 -04:00
jeremystretch
95783cc128 Closes #6644: Add 6P/4P pass-through port types 2021-07-28 11:54:25 -04:00
jeremystretch
8d9d3a9e7d Changelog and cleanup for #6560 2021-07-28 11:44:13 -04:00
Jeremy Stretch
ea0de4b01d Merge pull request #6561 from abigley/csv_feature
CSV file import
2021-07-28 10:48:30 -04:00
jeremystretch
72aaf76cf4 Closes #6702: Update reference nginx config to support IPv6 2021-07-28 10:31:59 -04:00
jeremystretch
78e282d406 Fixes #6771: Add count of inventory items to manufacturer view 2021-07-28 10:25:52 -04:00
jeremystretch
0c214932ba Fixes #6812: Limit reported prefix utilization to 100% 2021-07-28 09:55:40 -04:00
jeremystretch
a1eb4dc807 Fixes #5627: Fix filtering of interface connections list 2021-07-27 16:21:56 -04:00
jeremystretch
e92f13977c Changelog for #6785 2021-07-27 16:17:59 -04:00
Jeremy Stretch
5db283700f Merge pull request #6789 from bellwood/patch-1
Add AC Hardwire option to PowerPortTypeChoices
2021-07-27 16:14:01 -04:00
Jeremy Stretch
6e79e5608e Merge pull request #6810 from tamaszl/patch-1
Update 6-ldap.md - AUTH_LDAP_USER_DN_TEMPLATE to none for windows 2012+
2021-07-27 16:12:36 -04:00
jeremystretch
8355270a1a Fixes #6822: Use consistent maximum value for interface MTU 2021-07-27 16:04:51 -04:00
Brian Ellwood
1c38d63c50 Update choices.py 2021-07-26 15:03:43 -04:00
bluikko
4f6944424b Add dev server firewall configuration for EL distros (#6772)
* Add dev server firewall configuration for EL distros

* Fix typo in previous

* Indent the firewall block in install docs
2021-07-26 13:26:46 -04:00
tamaszl
7ab916b527 Update 6-ldap.md - AUTH_LDAP_USER_DN_TEMPLATE to none for windows 2012+
changed     When using Windows Server 2012, `AUTH_LDAP_USER_DN_TEMPLATE` should be set to None.
to Windows Server 2012+
2021-07-25 18:44:21 -07:00
jeremystretch
f25649955e Exclude NPM files from git (v3.0+) 2021-07-23 13:45:56 -04:00
jeremystretch
04d6a4a371 Introduce "adding models" section to development docs 2021-07-23 13:43:33 -04:00
jeremystretch
a8140d1f70 Closes #6781: Disable database query caching by default 2021-07-23 11:34:24 -04:00
jeremystretch
d1af15037c Fixes #6759: Fix assignment of parent interfaces for bulk import 2021-07-23 11:24:32 -04:00
jeremystretch
cca76550d6 Fixes #6794: Fix device name display on device status view 2021-07-23 11:18:50 -04:00
jeremystretch
2ff3d0d5a2 Fixes #6774: Fix A/Z assignment when swapping circuit terminations 2021-07-23 11:13:21 -04:00
Brian Ellwood
e300fad340 Add AC Hardwire option to PowerPortTypeChoices
Resolves FR #6785
2021-07-22 19:04:34 -04:00
Alyssa Bigley
1e7b76005c cleaned up validation error method 2021-06-14 15:23:42 -04:00
Alyssa Bigley
0a661596b3 moved duplicated code in CSV Fields into functions in forms/utils.py 2021-06-14 14:07:37 -04:00
Alyssa Bigley
934543b595 Caught and handled ValidationError 2021-06-11 13:42:26 -04:00
Alyssa Bigley
55b7cf21cc changed name of csv_file variable and started work on ValidationError 2021-06-10 14:41:33 -04:00
Alyssa Bigley
3549fc07f6 removed unnecessary use of seek() 2021-06-07 14:29:38 -04:00
Alyssa Bigley
ecd84d7c43 edited docstring for CSVFileField 2021-06-07 14:06:32 -04:00
Alyssa Bigley
c2b2b059e6 CSV import implemented using CSVFileField 2021-06-07 14:06:32 -04:00
Alyssa Bigley
6ff5a1db42 cleaned up csv parsing 2021-06-07 14:06:31 -04:00
Alyssa Bigley
2bc68707b5 csv parse using python csv library 2021-06-07 14:06:31 -04:00
Alyssa Bigley
0c9376039c working csv upload first draft 2021-06-07 14:06:31 -04:00
Alyssa Bigley
e1fe3ca14a CSV Upload as second field in existing form 2021-06-07 14:06:31 -04:00
31 changed files with 414 additions and 191 deletions

View File

@@ -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.9
placeholder: v2.11.10
validations:
required: true
- type: dropdown

View File

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

2
.gitignore vendored
View File

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

View File

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

View File

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

View File

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

View 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`.

View File

@@ -1,4 +1,4 @@
![NetBox](netbox_logo.svg "NetBox logo")
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
# What is NetBox?

View File

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

View File

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

View File

@@ -1,14 +1,31 @@
# NetBox v2.11
## v2.11.10 (FUTURE)
## 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
---

View File

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

View File

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

View File

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

View File

@@ -341,6 +341,8 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_DC = 'dc-terminal'
# Proprietary
TYPE_SAF_D_GRID = 'saf-d-grid'
# Other
TYPE_HARDWIRED = 'hardwired'
CHOICES = (
('IEC 60320', (
@@ -447,6 +449,9 @@ class PowerPortTypeChoices(ChoiceSet):
('Proprietary', (
(TYPE_SAF_D_GRID, 'Saf-D-Grid'),
)),
('Other', (
(TYPE_HARDWIRED, 'Hardwired'),
)),
)
@@ -917,6 +922,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 +958,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'),

View File

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

View File

@@ -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()
@@ -3173,12 +3179,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'
@@ -3378,13 +3378,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

View File

@@ -483,7 +483,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(

View File

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

View File

@@ -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(),
}
@@ -2564,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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '2.11.10-dev'
VERSION = '2.11.10'
# 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', [])
@@ -417,13 +417,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
}

View File

@@ -20,7 +20,8 @@ from extras.models import CustomField, ExportTemplate
from utilities.error_handlers import handle_protectederror
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
@@ -667,6 +668,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]
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)
@@ -691,7 +708,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")
@@ -699,7 +716,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)

View File

@@ -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' %}

View File

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

View File

@@ -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">&mdash;</span>
{% endif %}
</td>
<td>
{% if field.to_field_name %}
<code>{{ field.to_field_name }}</code>
{% else %}
<span class="text-muted">&mdash;</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">&times;</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">&mdash;</span>
{% endif %}
</td>
<td>
{% if field.to_field_name %}
<code>{{ field.to_field_name }}</code>
{% else %}
<span class="text-muted">&mdash;</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">&times;</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 %}

View File

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

View File

@@ -14,6 +14,8 @@ __all__ = (
'parse_alphanumeric_range',
'parse_numeric_range',
'restrict_form_fields',
'parse_csv',
'validate_csv',
)
@@ -134,3 +136,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.')

View File

@@ -16,10 +16,10 @@ from ipam.models import IPAddress, VLAN
from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, BulkRenameForm, CommentField,
ConfirmationForm, CSVChoiceField, CSVModelChoiceField, CSVModelForm, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea,
StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
add_blank_choice, BootstrapMixin, BulkEditNullBooleanSelect, BulkRenameForm, CommentField, ConfirmationForm,
CSVChoiceField, CSVModelChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
)
from .choices import *
from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -680,12 +680,6 @@ class VMInterfaceCreateForm(BootstrapMixin, CustomFieldForm, InterfaceCommonForm
'virtual_machine_id': '$virtual_machine',
}
)
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'

View File

@@ -263,7 +263,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
'name': 'Interface X',
'enabled': False,
'mac_address': EUI('01-02-03-04-05-06'),
'mtu': 2000,
'mtu': 65000,
'description': 'New description',
'mode': InterfaceModeChoices.MODE_TAGGED,
'untagged_vlan': vlans[0].pk,