mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
Merge v2.9.4 release
This commit is contained in:
@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi
|
||||
|
||||
---
|
||||
|
||||
## RQ_DEFAULT_TIMEOUT
|
||||
|
||||
Default: `300`
|
||||
|
||||
The maximum execution time of a background task (such as running a custom script), in seconds.
|
||||
|
||||
---
|
||||
|
||||
## SCRIPTS_ROOT
|
||||
|
||||
Default: `$INSTALL_ROOT/netbox/scripts/`
|
||||
|
@ -65,7 +65,6 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes
|
||||
* `PORT` - TCP port of the Redis service; leave blank for default port (6379)
|
||||
* `PASSWORD` - Redis password (if set)
|
||||
* `DATABASE` - Numeric database ID
|
||||
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
|
||||
* `SSL` - Use SSL connection to Redis
|
||||
|
||||
An example configuration is provided below:
|
||||
@ -77,7 +76,6 @@ REDIS = {
|
||||
'PORT': 1234,
|
||||
'PASSWORD': 'foobar',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@ -85,7 +83,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
@ -109,6 +106,7 @@ above and the addition of two new keys.
|
||||
* `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address
|
||||
of the Redis server and port for each sentinel instance to connect to
|
||||
* `SENTINEL_SERVICE`: Name of the master / service to connect to
|
||||
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
|
||||
|
||||
Example:
|
||||
|
||||
@ -117,9 +115,9 @@ REDIS = {
|
||||
'tasks': {
|
||||
'SENTINELS': [('mysentinel.redis.example.com', 6379)],
|
||||
'SENTINEL_SERVICE': 'netbox',
|
||||
'SENTINEL_TIMEOUT': 10,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@ -130,7 +128,6 @@ REDIS = {
|
||||
'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
@ -163,7 +163,6 @@ REDIS = {
|
||||
'PORT': 6379, # Redis port
|
||||
'PASSWORD': '', # Redis password (optional)
|
||||
'DATABASE': 0, # Database ID
|
||||
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
|
||||
'SSL': False, # Use SSL (optional)
|
||||
},
|
||||
'caching': {
|
||||
@ -171,7 +170,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1, # Unique ID for second database
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
@ -1,3 +1,3 @@
|
||||
## Rear Port Templates
|
||||
|
||||
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64).
|
||||
A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024).
|
||||
|
@ -1,5 +1,37 @@
|
||||
# NetBox v2.9
|
||||
|
||||
## v2.9.4 (2020-09-23)
|
||||
|
||||
**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead.
|
||||
|
||||
**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed
|
||||
* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024
|
||||
* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks
|
||||
* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form
|
||||
* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view
|
||||
* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release
|
||||
* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission
|
||||
* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces
|
||||
* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM
|
||||
* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data
|
||||
* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI
|
||||
* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params`
|
||||
* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API)
|
||||
* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface
|
||||
* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode
|
||||
* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list
|
||||
* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users
|
||||
|
||||
---
|
||||
|
||||
## v2.9.3 (2020-09-04)
|
||||
|
||||
### Enhancements
|
||||
@ -121,6 +153,7 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip
|
||||
* If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved.
|
||||
* If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.)
|
||||
* `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`.
|
||||
* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases.
|
||||
|
||||
### REST API Changes
|
||||
|
||||
|
@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
|
||||
#
|
||||
|
||||
REARPORT_POSITIONS_MIN = 1
|
||||
REARPORT_POSITIONS_MAX = 64
|
||||
REARPORT_POSITIONS_MAX = 1024
|
||||
|
||||
|
||||
#
|
||||
|
@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
|
||||
'region_id': '$region'
|
||||
}
|
||||
)
|
||||
rack_group = DynamicModelChoiceField(
|
||||
queryset=RackGroup.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
display_field='display_name',
|
||||
query_params={
|
||||
'site_id': '$site'
|
||||
'site_id': '$site',
|
||||
'group_id': '$rack_group',
|
||||
}
|
||||
)
|
||||
position = forms.TypedChoiceField(
|
||||
|
34
netbox/dcim/migrations/0116_rearport_max_positions.py
Normal file
34
netbox/dcim/migrations/0116_rearport_max_positions.py
Normal file
@ -0,0 +1,34 @@
|
||||
# Generated by Django 3.1 on 2020-09-16 16:51
|
||||
|
||||
import django.core.validators
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0115_rackreservation_order'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='frontport',
|
||||
name='rear_port_position',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='frontporttemplate',
|
||||
name='rear_port_position',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearport',
|
||||
name='positions',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='rearporttemplate',
|
||||
name='positions',
|
||||
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]),
|
||||
),
|
||||
]
|
@ -5,7 +5,7 @@ from django.db import migrations, models
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0115_rackreservation_order'),
|
||||
('dcim', '0116_rearport_max_positions'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -6,7 +6,7 @@ import mptt.fields
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0116_custom_field_data'),
|
||||
('dcim', '0117_custom_field_data'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -15,7 +15,7 @@ def rebuild_mptt(apps, schema_editor):
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0117_inventoryitem_mptt'),
|
||||
('dcim', '0118_inventoryitem_mptt'),
|
||||
]
|
||||
|
||||
operations = [
|
@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
rear_port_position = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
|
||||
)
|
||||
positions = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
@ -811,7 +811,10 @@ class FrontPort(CableTermination, ComponentModel):
|
||||
)
|
||||
rear_port_position = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
@ -866,7 +869,10 @@ class RearPort(CableTermination, ComponentModel):
|
||||
)
|
||||
positions = models.PositiveSmallIntegerField(
|
||||
default=1,
|
||||
validators=[MinValueValidator(1), MaxValueValidator(64)]
|
||||
validators=[
|
||||
MinValueValidator(REARPORT_POSITIONS_MIN),
|
||||
MaxValueValidator(REARPORT_POSITIONS_MAX)
|
||||
]
|
||||
)
|
||||
tags = TaggableManager(through=TaggedItem)
|
||||
|
||||
|
@ -168,9 +168,13 @@ class SiteView(ObjectView):
|
||||
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(),
|
||||
'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(),
|
||||
}
|
||||
rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate(
|
||||
rack_count=Count('racks')
|
||||
)
|
||||
rack_groups = RackGroup.objects.add_related_count(
|
||||
RackGroup.objects.all(),
|
||||
Rack,
|
||||
'group',
|
||||
'rack_count',
|
||||
cumulative=True
|
||||
).restrict(request.user, 'view').filter(site=site)
|
||||
|
||||
return render(request, 'dcim/site.html', {
|
||||
'site': site,
|
||||
@ -307,6 +311,11 @@ class RackElevationListView(ObjectListView):
|
||||
racks = filters.RackFilterSet(request.GET, self.queryset).qs
|
||||
total_count = racks.count()
|
||||
|
||||
# Determine ordering
|
||||
reverse = bool(request.GET.get('reverse', False))
|
||||
if reverse:
|
||||
racks = racks.reverse()
|
||||
|
||||
# Pagination
|
||||
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
page_number = request.GET.get('page', 1)
|
||||
@ -327,6 +336,7 @@ class RackElevationListView(ObjectListView):
|
||||
'paginator': paginator,
|
||||
'page': page,
|
||||
'total_count': total_count,
|
||||
'reverse': reverse,
|
||||
'rack_face': rack_face,
|
||||
'filter_form': forms.RackElevationFilterForm(request.GET),
|
||||
})
|
||||
@ -405,7 +415,6 @@ class RackReservationListView(ObjectListView):
|
||||
filterset = filters.RackReservationFilterSet
|
||||
filterset_form = forms.RackReservationFilterForm
|
||||
table = tables.RackReservationTable
|
||||
action_buttons = ('export',)
|
||||
|
||||
|
||||
class RackReservationView(ObjectView):
|
||||
|
@ -57,24 +57,30 @@ class TaggedObjectSerializer(serializers.Serializer):
|
||||
tags = NestedTagSerializer(many=True, required=False)
|
||||
|
||||
def create(self, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
tags = validated_data.pop('tags', None)
|
||||
instance = super().create(validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
tags = validated_data.pop('tags', [])
|
||||
tags = validated_data.pop('tags', None)
|
||||
|
||||
# Cache tags on instance for change logging
|
||||
instance._tags = tags
|
||||
instance._tags = tags or []
|
||||
|
||||
instance = super().update(instance, validated_data)
|
||||
|
||||
return self._save_tags(instance, tags)
|
||||
if tags is not None:
|
||||
return self._save_tags(instance, tags)
|
||||
return instance
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
instance.tags.set(*[t.name for t in tags])
|
||||
else:
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
|
||||
|
@ -1,4 +1,5 @@
|
||||
import django_filters
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db.models import Q
|
||||
|
||||
@ -236,6 +237,16 @@ class ObjectChangeFilterSet(BaseFilterSet):
|
||||
)
|
||||
time = django_filters.DateTimeFromToRangeFilter()
|
||||
changed_object_type = ContentTypeFilter()
|
||||
user_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=User.objects.all(),
|
||||
label='User (ID)',
|
||||
)
|
||||
user = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='user__username',
|
||||
queryset=User.objects.all(),
|
||||
to_field_name='username',
|
||||
label='User name',
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = ObjectChange
|
||||
|
@ -353,10 +353,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
|
||||
required=False,
|
||||
widget=StaticSelect2()
|
||||
)
|
||||
user = DynamicModelMultipleChoiceField(
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
display_field='username',
|
||||
label='User',
|
||||
widget=APISelectMultiple(
|
||||
api_url='/api/users/users/',
|
||||
)
|
||||
|
@ -1,7 +1,12 @@
|
||||
import time
|
||||
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.management.base import BaseCommand
|
||||
from django.utils import timezone
|
||||
|
||||
from extras.reports import get_reports
|
||||
from extras.choices import JobResultStatusChoices
|
||||
from extras.models import JobResult
|
||||
from extras.reports import get_reports, run_report
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
@ -20,15 +25,33 @@ class Command(BaseCommand):
|
||||
for report in report_list:
|
||||
if module_name in options['reports'] or report.full_name in options['reports']:
|
||||
|
||||
# Run the report and create a new ReportResult
|
||||
# Run the report and create a new JobResult
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
|
||||
)
|
||||
report.run()
|
||||
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
job_result = JobResult.enqueue_job(
|
||||
run_report,
|
||||
report.full_name,
|
||||
report_content_type,
|
||||
None
|
||||
)
|
||||
|
||||
# Wait on the job to finish
|
||||
while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
time.sleep(1)
|
||||
job_result = JobResult.objects.get(pk=job_result.pk)
|
||||
|
||||
# Report on success/failure
|
||||
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS')
|
||||
for test_name, attrs in report.result.data.items():
|
||||
if job_result.status == JobResultStatusChoices.STATUS_FAILED:
|
||||
status = self.style.ERROR('FAILED')
|
||||
elif job_result == JobResultStatusChoices.STATUS_ERRORED:
|
||||
status = self.style.ERROR('ERRORED')
|
||||
else:
|
||||
status = self.style.SUCCESS('SUCCESS')
|
||||
|
||||
for test_name, attrs in job_result.data.items():
|
||||
self.stdout.write(
|
||||
"\t{}: {} success, {} info, {} warning, {} failure".format(
|
||||
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
|
||||
@ -37,6 +60,9 @@ class Command(BaseCommand):
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
|
||||
)
|
||||
self.stdout.write(
|
||||
"[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration)
|
||||
)
|
||||
|
||||
# Wrap things up
|
||||
self.stdout.write(
|
||||
|
@ -55,7 +55,7 @@ class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('circuits', '0020_custom_field_data'),
|
||||
('dcim', '0116_custom_field_data'),
|
||||
('dcim', '0117_custom_field_data'),
|
||||
('extras', '0050_customfield_add_choices'),
|
||||
('ipam', '0038_custom_field_data'),
|
||||
('secrets', '0010_custom_field_data'),
|
||||
|
@ -381,12 +381,11 @@ class ObjectChangeTestCase(TestCase):
|
||||
params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||
|
||||
# TODO: Merge #5167 from develop
|
||||
# def test_user(self):
|
||||
# params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
# params = {'user': ['user1', 'user2']}
|
||||
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
def test_user(self):
|
||||
params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
params = {'user': ['user1', 'user2']}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||
|
||||
def test_user_name(self):
|
||||
params = {'user_name': ['user1', 'user2']}
|
||||
|
@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
|
||||
sorted([t.name for t in site.tags.all()]),
|
||||
sorted(["Foo", "Bar", "New Tag"])
|
||||
)
|
||||
|
||||
def test_clear_tagged_item(self):
|
||||
site = Site.objects.create(
|
||||
name='Test Site',
|
||||
slug='test-site'
|
||||
)
|
||||
site.tags.add("Foo", "Bar", "Baz")
|
||||
data = {
|
||||
'tags': []
|
||||
}
|
||||
self.add_permissions('dcim.change_site')
|
||||
url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk})
|
||||
|
||||
response = self.client.patch(url, data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data['tags']), 0)
|
||||
site = Site.objects.get(pk=response.data['id'])
|
||||
self.assertEqual(len(site.tags.all()), 0)
|
||||
|
@ -315,7 +315,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
|
||||
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_reportresult'
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request):
|
||||
|
||||
@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
|
||||
Display a single Report and its associated JobResult (if any).
|
||||
"""
|
||||
def get_required_permission(self):
|
||||
return 'extras.view_reportresult'
|
||||
return 'extras.view_report'
|
||||
|
||||
def get(self, request, module, name):
|
||||
|
||||
|
@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Cannot select both a device interface and a VM interface
|
||||
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'):
|
||||
raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface")
|
||||
self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
|
||||
# Primary IP assignment is only available if an interface has been assigned.
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
@ -655,26 +655,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
# Set assigned object
|
||||
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
|
||||
if interface:
|
||||
self.instance.assigned_object = interface
|
||||
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine.
|
||||
interface = self.instance.assigned_object
|
||||
if interface and self.cleaned_data['primary_for_parent']:
|
||||
if ipaddress.address.version == 4:
|
||||
interface.parent.primary_ip4 = ipaddress
|
||||
else:
|
||||
interface.primary_ip6 = ipaddress
|
||||
interface.parent.primary_ip6 = ipaddress
|
||||
interface.parent.save()
|
||||
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
|
||||
interface.parent.primary_ip4 = None
|
||||
interface.parent.save()
|
||||
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress:
|
||||
interface.parent.primary_ip4 = None
|
||||
interface.parent.primary_ip6 = None
|
||||
interface.parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
@ -707,30 +707,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
|
||||
})
|
||||
|
||||
# Check for primary IP assignment that doesn't match the assigned device/VM
|
||||
if self.pk and type(self.assigned_object) is Interface:
|
||||
if self.pk:
|
||||
device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if device:
|
||||
if self.assigned_object is None:
|
||||
if getattr(self.assigned_object, 'device', None) != device:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for device {device} but not assigned to an interface"
|
||||
'interface': f"IP address is primary for device {device} but not assigned to it!"
|
||||
})
|
||||
elif self.assigned_object.device != device:
|
||||
raise ValidationError({
|
||||
'interface': f"IP address is primary for device {device} but assigned to "
|
||||
f"{self.assigned_object.device} ({self.assigned_object})"
|
||||
})
|
||||
elif self.pk and type(self.assigned_object) is VMInterface:
|
||||
vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
|
||||
if vm:
|
||||
if self.assigned_object is None:
|
||||
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 an "
|
||||
f"interface"
|
||||
})
|
||||
elif self.assigned_object.virtual_machine != vm:
|
||||
raise ValidationError({
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but assigned to "
|
||||
f"{self.assigned_object.virtual_machine} ({self.assigned_object})"
|
||||
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
|
||||
})
|
||||
|
||||
# Validate IP status selection
|
||||
@ -973,13 +961,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
|
||||
def get_status_class(self):
|
||||
return self.STATUS_CLASS_MAP[self.status]
|
||||
|
||||
def get_members(self):
|
||||
# Return all interfaces assigned to this VLAN
|
||||
def get_interfaces(self):
|
||||
# Return all device interfaces assigned to this VLAN
|
||||
return Interface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
).distinct()
|
||||
|
||||
def get_vminterfaces(self):
|
||||
# Return all VM interfaces assigned to this VLAN
|
||||
return VMInterface.objects.filter(
|
||||
Q(untagged_vlan_id=self.pk) |
|
||||
Q(tagged_vlans=self.pk)
|
||||
).distinct()
|
||||
|
||||
|
||||
@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
|
||||
class Service(ChangeLoggedModel, CustomFieldModel):
|
||||
|
@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
|
||||
from dcim.models import Interface
|
||||
from tenancy.tables import COL_TENANT
|
||||
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn
|
||||
from virtualization.models import VMInterface
|
||||
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
|
||||
|
||||
RIR_UTILIZATION = """
|
||||
@ -124,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
|
||||
{% endwith %}
|
||||
"""
|
||||
|
||||
VLAN_MEMBER_UNTAGGED = """
|
||||
VLAN_MEMBER_TAGGED = """
|
||||
{% if record.untagged_vlan_id == vlan.pk %}
|
||||
<i class="glyphicon glyphicon-ok">
|
||||
<span class="text-danger"><i class="fa fa-close"></i></span>
|
||||
{% else %}
|
||||
<span class="text-success"><i class="fa fa-check"></i></span>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@ -415,7 +418,7 @@ class IPAddressDetailTable(IPAddressTable):
|
||||
tenant = tables.TemplateColumn(
|
||||
template_code=COL_TENANT
|
||||
)
|
||||
assigned = tables.BooleanColumn(
|
||||
assigned = BooleanColumn(
|
||||
accessor='assigned_object_id',
|
||||
verbose_name='Assigned'
|
||||
)
|
||||
@ -553,15 +556,15 @@ class VLANDetailTable(VLANTable):
|
||||
default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
|
||||
|
||||
|
||||
class VLANMemberTable(BaseTable):
|
||||
parent = tables.LinkColumn(
|
||||
order_by=['device', 'virtual_machine']
|
||||
)
|
||||
class VLANMembersTable(BaseTable):
|
||||
"""
|
||||
Base table for Interface and VMInterface assignments
|
||||
"""
|
||||
name = tables.LinkColumn(
|
||||
verbose_name='Interface'
|
||||
)
|
||||
untagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_UNTAGGED,
|
||||
tagged = tables.TemplateColumn(
|
||||
template_code=VLAN_MEMBER_TAGGED,
|
||||
orderable=False
|
||||
)
|
||||
actions = tables.TemplateColumn(
|
||||
@ -570,9 +573,21 @@ class VLANMemberTable(BaseTable):
|
||||
verbose_name=''
|
||||
)
|
||||
|
||||
|
||||
class VLANDevicesTable(VLANMembersTable):
|
||||
device = tables.LinkColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = Interface
|
||||
fields = ('parent', 'name', 'untagged', 'actions')
|
||||
fields = ('device', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class VLANVirtualMachinesTable(VLANMembersTable):
|
||||
virtual_machine = tables.LinkColumn()
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
model = VMInterface
|
||||
fields = ('virtual_machine', 'name', 'tagged', 'actions')
|
||||
|
||||
|
||||
class InterfaceVLANTable(BaseTable):
|
||||
|
@ -90,7 +90,8 @@ urlpatterns = [
|
||||
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
|
||||
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
|
||||
path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
|
||||
path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'),
|
||||
path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
|
||||
path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
|
||||
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
|
||||
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
|
||||
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
|
||||
|
@ -749,15 +749,13 @@ class VLANView(ObjectView):
|
||||
})
|
||||
|
||||
|
||||
class VLANMembersView(ObjectView):
|
||||
class VLANInterfacesView(ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine')
|
||||
|
||||
members_table = tables.VLANMemberTable(members)
|
||||
interfaces = vlan.get_interfaces().prefetch_related('device')
|
||||
members_table = tables.VLANDevicesTable(interfaces)
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
@ -765,10 +763,31 @@ class VLANMembersView(ObjectView):
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
return render(request, 'ipam/vlan_members.html', {
|
||||
return render(request, 'ipam/vlan_interfaces.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
'active_tab': 'members',
|
||||
'active_tab': 'interfaces',
|
||||
})
|
||||
|
||||
|
||||
class VLANVMInterfacesView(ObjectView):
|
||||
queryset = VLAN.objects.all()
|
||||
|
||||
def get(self, request, pk):
|
||||
vlan = get_object_or_404(self.queryset, pk=pk)
|
||||
interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine')
|
||||
members_table = tables.VLANVirtualMachinesTable(interfaces)
|
||||
|
||||
paginate = {
|
||||
'paginator_class': EnhancedPaginator,
|
||||
'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT)
|
||||
}
|
||||
RequestConfig(request, paginate).configure(members_table)
|
||||
|
||||
return render(request, 'ipam/vlan_vminterfaces.html', {
|
||||
'vlan': vlan,
|
||||
'members_table': members_table,
|
||||
'active_tab': 'vminterfaces',
|
||||
})
|
||||
|
||||
|
||||
|
@ -33,7 +33,6 @@ REDIS = {
|
||||
# 'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@ -44,7 +43,6 @@ REDIS = {
|
||||
# 'SENTINEL_SERVICE': 'netbox',
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
|
||||
# this setting is derived from the installed location.
|
||||
# REPORTS_ROOT = '/opt/netbox/netbox/reports'
|
||||
|
||||
# Maximum execution time for background tasks, in seconds.
|
||||
RQ_DEFAULT_TIMEOUT = 300
|
||||
|
||||
# The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of
|
||||
# this setting is derived from the installed location.
|
||||
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'
|
||||
|
@ -24,7 +24,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 0,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
},
|
||||
'caching': {
|
||||
@ -32,7 +31,6 @@ REDIS = {
|
||||
'PORT': 6379,
|
||||
'PASSWORD': '',
|
||||
'DATABASE': 1,
|
||||
'DEFAULT_TIMEOUT': 300,
|
||||
'SSL': False,
|
||||
}
|
||||
}
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '2.9.4-dev'
|
||||
VERSION = '2.10-beta1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -110,6 +110,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U
|
||||
RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
|
||||
RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
|
||||
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/')
|
||||
RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300)
|
||||
SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
|
||||
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
|
||||
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
|
||||
@ -132,6 +133,7 @@ if RELEASE_CHECK_URL:
|
||||
if RELEASE_CHECK_TIMEOUT < 3600:
|
||||
raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
|
||||
|
||||
|
||||
#
|
||||
# Database
|
||||
#
|
||||
@ -201,10 +203,13 @@ TASKS_REDIS_USING_SENTINEL = all([
|
||||
len(TASKS_REDIS_SENTINELS) > 0
|
||||
])
|
||||
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default')
|
||||
TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10)
|
||||
TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
|
||||
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0)
|
||||
TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300)
|
||||
TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False)
|
||||
# TODO: Remove in v2.10 (see #5171)
|
||||
if 'DEFAULT_TIMEOUT' in TASKS_REDIS:
|
||||
warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.')
|
||||
|
||||
# Caching
|
||||
if 'caching' not in REDIS:
|
||||
@ -222,7 +227,6 @@ CACHING_REDIS_USING_SENTINEL = all([
|
||||
CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
|
||||
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
|
||||
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0)
|
||||
CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300)
|
||||
CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
|
||||
|
||||
|
||||
@ -538,7 +542,7 @@ if TASKS_REDIS_USING_SENTINEL:
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'SOCKET_TIMEOUT': None,
|
||||
'CONNECTION_KWARGS': {
|
||||
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT
|
||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||
},
|
||||
}
|
||||
else:
|
||||
@ -547,8 +551,8 @@ else:
|
||||
'PORT': TASKS_REDIS_PORT,
|
||||
'DB': TASKS_REDIS_DATABASE,
|
||||
'PASSWORD': TASKS_REDIS_PASSWORD,
|
||||
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
|
||||
'SSL': TASKS_REDIS_SSL,
|
||||
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
|
||||
}
|
||||
|
||||
RQ_QUEUES = {
|
||||
|
@ -11,11 +11,8 @@
|
||||
<div class="row noprint">
|
||||
<div class="col-sm-8 col-md-9">
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li>
|
||||
{% if device.rack %}
|
||||
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
|
||||
{% endif %}
|
||||
<li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
|
||||
<li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
|
||||
{% if device.parent_bay %}
|
||||
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
|
||||
<li>{{ device.parent_bay }}</li>
|
||||
|
@ -23,6 +23,7 @@
|
||||
<div class="panel-body">
|
||||
{% render_field form.region %}
|
||||
{% render_field form.site %}
|
||||
{% render_field form.rack_group %}
|
||||
{% render_field form.rack %}
|
||||
{% if obj.device_type.is_child_device and obj.parent_bay %}
|
||||
<div class="form-group">
|
||||
|
@ -11,6 +11,12 @@
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
|
||||
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
|
||||
{% if rack.group %}
|
||||
{% for group in rack.group.get_ancestors %}
|
||||
<li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a></li>
|
||||
{% endif %}
|
||||
<li>{{ rack }}</li>
|
||||
</ol>
|
||||
</div>
|
||||
@ -87,7 +93,10 @@
|
||||
<td>Group</td>
|
||||
<td>
|
||||
{% if rack.group %}
|
||||
<a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a>
|
||||
{% for group in rack.group.get_ancestors %}
|
||||
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
|
||||
{% endfor %}
|
||||
<a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
|
@ -3,12 +3,18 @@
|
||||
{% load static %}
|
||||
|
||||
{% block content %}
|
||||
<div class="btn-group pull-right noprint" role="group">
|
||||
<div class="btn-toolbar pull-right noprint" role="toolbar">
|
||||
<button class="btn btn-default toggle-images" selected="selected">
|
||||
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
|
||||
</button>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
|
||||
</div>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
|
||||
</div>
|
||||
</div>
|
||||
<h1>{% block title %}Rack Elevations{% endblock %}</h1>
|
||||
<div class="row">
|
||||
|
@ -12,7 +12,7 @@
|
||||
<ol class="breadcrumb">
|
||||
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
|
||||
{% if site.region %}
|
||||
{% for region in site.region.get_ancestors.unrestricted %}
|
||||
{% for region in site.region.get_ancestors %}
|
||||
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
|
||||
{% endfor %}
|
||||
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
|
||||
@ -80,7 +80,7 @@
|
||||
<td>Region</td>
|
||||
<td>
|
||||
{% if site.region %}
|
||||
{% for region in site.region.get_ancestors.unrestricted %}
|
||||
{% for region in site.region.get_ancestors %}
|
||||
<a href="{{ region.get_absolute_url }}">{{ region }}</a>
|
||||
<i class="fa fa-angle-right"></i>
|
||||
{% endfor %}
|
||||
@ -249,7 +249,7 @@
|
||||
<table class="table table-hover panel-body">
|
||||
{% for rg in rack_groups %}
|
||||
<tr>
|
||||
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
|
||||
<td style="padding-left: {{ rg.level }}8px"><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
|
||||
<td>{{ rg.rack_count }}</td>
|
||||
<td class="text-right noprint">
|
||||
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">
|
||||
|
@ -5,14 +5,15 @@
|
||||
A module import error occurred during this request. Common causes include the following:
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required
|
||||
Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part
|
||||
of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the
|
||||
console and compare the output to the list of required packages.
|
||||
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
|
||||
missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
|
||||
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
|
||||
To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
|
||||
required packages.
|
||||
</p>
|
||||
<p>
|
||||
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded,
|
||||
check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is
|
||||
running.
|
||||
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
|
||||
has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
|
||||
ensures that the new code is running.
|
||||
</p>
|
||||
{% endblock %}
|
||||
|
@ -276,7 +276,7 @@
|
||||
<div class="panel-heading">
|
||||
<strong>Reports</strong>
|
||||
</div>
|
||||
{% if report_results and perms.extras.view_reportresult %}
|
||||
{% if report_results and perms.extras.view_report %}
|
||||
<table class="table table-hover panel-body">
|
||||
{% for result in report_results %}
|
||||
<tr>
|
||||
@ -285,7 +285,7 @@
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% elif perms.extras.view_reportresult %}
|
||||
{% elif perms.extras.view_report %}
|
||||
<div class="panel-body text-muted">
|
||||
None found
|
||||
</div>
|
||||
|
@ -518,7 +518,7 @@
|
||||
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:script_list' %}">Scripts</a>
|
||||
</li>
|
||||
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}>
|
||||
<li{% if not perms.extras.view_report %} class="disabled"{% endif %}>
|
||||
<a href="{% url 'extras:report_list' %}">Reports</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
@ -52,8 +52,11 @@
|
||||
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
|
||||
<li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_interfaces' pk=vlan.pk %}">Device Interfaces <span class="badge">{{ vlan.get_interfaces.count }}</span></a>
|
||||
</li>
|
||||
<li role="presentation"{% if active_tab == 'vminterfaces' %} class="active"{% endif %}>
|
||||
<a href="{% url 'ipam:vlan_vminterfaces' pk=vlan.pk %}">VM Interfaces <span class="badge">{{ vlan.get_vminterfaces.count }}</span></a>
|
||||
</li>
|
||||
{% if perms.extras.view_objectchange %}
|
||||
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
|
||||
|
@ -1,11 +1,9 @@
|
||||
{% extends 'ipam/vlan.html' %}
|
||||
|
||||
{% block title %}{{ block.super }} - Members{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %}
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
9
netbox/templates/ipam/vlan_vminterfaces.html
Normal file
9
netbox/templates/ipam/vlan_vminterfaces.html
Normal file
@ -0,0 +1,9 @@
|
||||
{% extends 'ipam/vlan.html' %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
|
||||
key = f'data-query-param-{name}'
|
||||
|
||||
values = json.loads(self.attrs.get(key, '[]'))
|
||||
if type(value) is list:
|
||||
if type(value) in (list, tuple):
|
||||
values.extend([str(v) for v in value])
|
||||
else:
|
||||
values.append(str(value))
|
||||
|
@ -114,12 +114,12 @@ class BooleanColumn(tables.Column):
|
||||
character.
|
||||
"""
|
||||
def render(self, value):
|
||||
if value is True:
|
||||
if value:
|
||||
rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
|
||||
elif value is False:
|
||||
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
|
||||
else:
|
||||
elif value is None:
|
||||
rendered = '<span class="text-muted">—</span>'
|
||||
else:
|
||||
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
|
||||
return mark_safe(rendered)
|
||||
|
||||
|
||||
|
@ -267,7 +267,7 @@ class APIViewTestCases:
|
||||
response = self.client.patch(url, update_data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
instance.refresh_from_db()
|
||||
self.assertInstanceEqual(instance, self.update_data, api=True)
|
||||
self.assertInstanceEqual(instance, update_data, api=True)
|
||||
|
||||
def test_bulk_update_objects(self):
|
||||
"""
|
||||
|
@ -936,7 +936,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
|
||||
|
||||
# ManyToManyFields
|
||||
elif isinstance(model_field, ManyToManyField):
|
||||
if form.cleaned_data[name].count() > 0:
|
||||
if form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
# Normal fields
|
||||
elif form.cleaned_data[name] not in (None, ''):
|
||||
|
@ -83,6 +83,7 @@ def replicate_interfaces(apps, schema_editor):
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0082_3569_interface_fields'),
|
||||
('ipam', '0037_ipaddress_assignment'),
|
||||
('virtualization', '0015_vminterface'),
|
||||
]
|
||||
|
Reference in New Issue
Block a user