mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
9c8432cf13
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: v3.0.4
|
||||
placeholder: v3.0.5
|
||||
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: v3.0.4
|
||||
placeholder: v3.0.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -45,6 +45,20 @@ Defining script variables is optional: You may create a script with only a `run(
|
||||
|
||||
Any output generated by the script during its execution will be displayed under the "output" tab in the UI.
|
||||
|
||||
By default, scripts within a module are ordered alphabetically in the scripts list page. To return scripts in a specific order, you can define the `script_order` variable at the end of your module. The `script_order` variable is a tuple which contains each Script class in the desired order. Any scripts that are omitted from this list will be listed last.
|
||||
|
||||
```python
|
||||
from extras.scripts import Script
|
||||
|
||||
class MyCustomScript(Script):
|
||||
...
|
||||
|
||||
class AnotherCustomScript(Script):
|
||||
...
|
||||
|
||||
script_order = (MyCustomScript, AnotherCustomScript)
|
||||
```
|
||||
|
||||
## Module Attributes
|
||||
|
||||
### `name`
|
||||
|
@ -11,7 +11,7 @@ The following sections detail how to set up a new instance of NetBox:
|
||||
5. [HTTP server](5-http-server.md)
|
||||
6. [LDAP authentication](6-ldap.md) (optional)
|
||||
|
||||
The video below demonstrates the installation of NetBox v2.10.3 on Ubuntu 20.04 for your reference.
|
||||
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
|
||||
|
||||
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
|
||||
|
||||
|
@ -1,6 +1,28 @@
|
||||
# NetBox v3.0
|
||||
|
||||
## v3.0.5 (FUTURE)
|
||||
## v3.0.5 (2021-10-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#5925](https://github.com/netbox-community/netbox/issues/5925) - Always show IP addresses tab under prefix view
|
||||
* [#6423](https://github.com/netbox-community/netbox/issues/6423) - Cache rendered REST API specifications
|
||||
* [#6708](https://github.com/netbox-community/netbox/issues/6708) - Add image attachment support for circuits, power panels
|
||||
* [#7387](https://github.com/netbox-community/netbox/issues/7387) - Enable arbitrary ordering of custom scripts
|
||||
* [#7427](https://github.com/netbox-community/netbox/issues/7427) - Don't select hidden rows when selecting all in a table
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#6433](https://github.com/netbox-community/netbox/issues/6433) - Fix bulk editing of child prefixes under aggregate view
|
||||
* [#6817](https://github.com/netbox-community/netbox/issues/6817) - Custom field columns should be removed from tables upon their deletion
|
||||
* [#6895](https://github.com/netbox-community/netbox/issues/6895) - Remove errant markup for null values in CSV export
|
||||
* [#7215](https://github.com/netbox-community/netbox/issues/7215) - Prevent rack elevations from overlapping when higher width is specified
|
||||
* [#7373](https://github.com/netbox-community/netbox/issues/7373) - Fix flashing when server, client, and browser color-mode preferences are mismatched
|
||||
* [#7397](https://github.com/netbox-community/netbox/issues/7397) - Fix AttributeError exception when rendering export template for devices via REST API
|
||||
* [#7401](https://github.com/netbox-community/netbox/issues/7401) - Pin `jsonschema` package to v3.2.0 to fix REST API docs rendering
|
||||
* [#7411](https://github.com/netbox-community/netbox/issues/7411) - Fix exception in UI when adding member devices to virtual chassis
|
||||
* [#7412](https://github.com/netbox-community/netbox/issues/7412) - Fix exception in UI when adding child device to device bay
|
||||
* [#7417](https://github.com/netbox-community/netbox/issues/7417) - Prevent exception when filtering objects list by invalid tag
|
||||
* [#7425](https://github.com/netbox-community/netbox/issues/7425) - Housekeeping command should honor zero verbosity
|
||||
|
||||
---
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@ -202,6 +203,9 @@ class Circuit(PrimaryModel):
|
||||
comments = models.TextField(
|
||||
blank=True
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
# Cache associated CircuitTerminations
|
||||
termination_a = models.ForeignKey(
|
||||
|
@ -38,6 +38,7 @@ __all__ = (
|
||||
'LocationForm',
|
||||
'ManufacturerForm',
|
||||
'PlatformForm',
|
||||
'PopulateDeviceBayForm',
|
||||
'PowerFeedForm',
|
||||
'PowerOutletForm',
|
||||
'PowerOutletTemplateForm',
|
||||
@ -52,6 +53,7 @@ __all__ = (
|
||||
'RegionForm',
|
||||
'SiteForm',
|
||||
'SiteGroupForm',
|
||||
'VCMemberSelectForm',
|
||||
'VirtualChassisForm',
|
||||
)
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import MaxValueValidator, MinValueValidator
|
||||
from django.db import models
|
||||
@ -39,6 +40,9 @@ class PowerPanel(PrimaryModel):
|
||||
name = models.CharField(
|
||||
max_length=100
|
||||
)
|
||||
images = GenericRelation(
|
||||
to='extras.ImageAttachment'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
|
@ -2,7 +2,7 @@ import django_tables2 as tables
|
||||
from django_tables2.utils import Accessor
|
||||
|
||||
from dcim.models import Cable
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, ChoiceFieldColumn, ColorColumn, TagColumn, TemplateColumn, ToggleColumn
|
||||
from .template_code import CABLE_LENGTH, CABLE_TERMINATION_PARENT
|
||||
|
||||
__all__ = (
|
||||
@ -45,7 +45,7 @@ class CableTable(BaseTable):
|
||||
verbose_name='Termination B'
|
||||
)
|
||||
status = ChoiceFieldColumn()
|
||||
length = tables.TemplateColumn(
|
||||
length = TemplateColumn(
|
||||
template_code=CABLE_LENGTH,
|
||||
order_by='_abs_length'
|
||||
)
|
||||
|
@ -9,7 +9,7 @@ from dcim.models import (
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
|
||||
MarkdownColumn, TagColumn, ToggleColumn,
|
||||
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
|
||||
)
|
||||
from .template_code import (
|
||||
CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
|
||||
@ -258,7 +258,7 @@ class CableTerminationTable(BaseTable):
|
||||
orderable=False,
|
||||
verbose_name='Cable Color'
|
||||
)
|
||||
cable_peer = tables.TemplateColumn(
|
||||
cable_peer = TemplateColumn(
|
||||
accessor='_cable_peer',
|
||||
template_code=CABLETERMINATION,
|
||||
orderable=False,
|
||||
@ -268,7 +268,7 @@ class CableTerminationTable(BaseTable):
|
||||
|
||||
|
||||
class PathEndpointTable(CableTerminationTable):
|
||||
connection = tables.TemplateColumn(
|
||||
connection = TemplateColumn(
|
||||
accessor='_path.last_node',
|
||||
template_code=CABLETERMINATION,
|
||||
verbose_name='Connection',
|
||||
@ -470,7 +470,7 @@ class BaseInterfaceTable(BaseTable):
|
||||
verbose_name='IP Addresses'
|
||||
)
|
||||
untagged_vlan = tables.Column(linkify=True)
|
||||
tagged_vlans = tables.TemplateColumn(
|
||||
tagged_vlans = TemplateColumn(
|
||||
template_code=INTERFACE_TAGGED_VLANS,
|
||||
orderable=False,
|
||||
verbose_name='Tagged VLANs'
|
||||
|
@ -5,13 +5,11 @@ CABLETERMINATION = """
|
||||
<i class="mdi mdi-chevron-right"></i>
|
||||
{% endif %}
|
||||
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_LENGTH = """
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% else %}—{% endif %}
|
||||
{% if record.length %}{{ record.length }} {{ record.get_length_unit_display }}{% endif %}
|
||||
"""
|
||||
|
||||
CABLE_TERMINATION_PARENT = """
|
||||
@ -63,8 +61,6 @@ INTERFACE_TAGGED_VLANS = """
|
||||
{% endfor %}
|
||||
{% elif record.mode == 'tagged-all' %}
|
||||
All
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
from django.urls import path
|
||||
|
||||
from extras.views import ImageAttachmentEditView, ObjectChangeLogView, ObjectJournalView
|
||||
from extras.views import ObjectChangeLogView, ObjectJournalView
|
||||
from ipam.views import ServiceEditView
|
||||
from utilities.views import SlugRedirectView
|
||||
from . import views
|
||||
@ -43,7 +43,6 @@ urlpatterns = [
|
||||
path('sites/<int:pk>/delete/', views.SiteDeleteView.as_view(), name='site_delete'),
|
||||
path('sites/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='site_changelog', kwargs={'model': Site}),
|
||||
path('sites/<int:pk>/journal/', ObjectJournalView.as_view(), name='site_journal', kwargs={'model': Site}),
|
||||
path('sites/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='site_add_image', kwargs={'model': Site}),
|
||||
|
||||
# Locations
|
||||
path('locations/', views.LocationListView.as_view(), name='location_list'),
|
||||
@ -55,7 +54,6 @@ urlpatterns = [
|
||||
path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
|
||||
path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
|
||||
path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
|
||||
path('locations/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='location_add_image', kwargs={'model': Location}),
|
||||
|
||||
# Rack roles
|
||||
path('rack-roles/', views.RackRoleListView.as_view(), name='rackrole_list'),
|
||||
@ -92,7 +90,6 @@ urlpatterns = [
|
||||
path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
|
||||
path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
|
||||
path('racks/<int:pk>/journal/', ObjectJournalView.as_view(), name='rack_journal', kwargs={'model': Rack}),
|
||||
path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
|
||||
|
||||
# Manufacturers
|
||||
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
|
||||
@ -229,7 +226,6 @@ urlpatterns = [
|
||||
path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
|
||||
path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
|
||||
path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
|
||||
path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
|
||||
|
||||
# Console ports
|
||||
path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
|
||||
|
@ -18,48 +18,60 @@ class Command(BaseCommand):
|
||||
def handle(self, *args, **options):
|
||||
|
||||
# Clear expired authentication sessions (essentially replicating the `clearsessions` command)
|
||||
self.stdout.write("[*] Clearing expired authentication sessions")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Clearing expired authentication sessions")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tConfigured session engine: {settings.SESSION_ENGINE}")
|
||||
engine = import_module(settings.SESSION_ENGINE)
|
||||
try:
|
||||
engine.SessionStore.clear_expired()
|
||||
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
|
||||
if options['verbosity']:
|
||||
self.stdout.write("\tSessions cleared.", self.style.SUCCESS)
|
||||
except NotImplementedError:
|
||||
self.stdout.write(
|
||||
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
if options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tThe configured session engine ({settings.SESSION_ENGINE}) does not support "
|
||||
f"clearing sessions; skipping."
|
||||
)
|
||||
|
||||
# Delete expired ObjectRecords
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for expired changelog records")
|
||||
if settings.CHANGELOG_RETENTION:
|
||||
cutoff = timezone.now() - timedelta(days=settings.CHANGELOG_RETENTION)
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"Retention period: {settings.CHANGELOG_RETENTION} days")
|
||||
self.stdout.write(f"\tRetention period: {settings.CHANGELOG_RETENTION} days")
|
||||
self.stdout.write(f"\tCut-off time: {cutoff}")
|
||||
expired_records = ObjectChange.objects.filter(time__lt=cutoff).count()
|
||||
if expired_records:
|
||||
self.stdout.write(f"\tDeleting {expired_records} expired records... ", self.style.WARNING, ending="")
|
||||
self.stdout.flush()
|
||||
if options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tDeleting {expired_records} expired records... ",
|
||||
self.style.WARNING,
|
||||
ending=""
|
||||
)
|
||||
self.stdout.flush()
|
||||
ObjectChange.objects.filter(time__lt=cutoff)._raw_delete(using=DEFAULT_DB_ALIAS)
|
||||
self.stdout.write("Done.", self.style.WARNING)
|
||||
else:
|
||||
self.stdout.write("\tNo expired records found.")
|
||||
else:
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Done.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
self.stdout.write("\tNo expired records found.", self.style.SUCCESS)
|
||||
elif options['verbosity']:
|
||||
self.stdout.write(
|
||||
f"\tSkipping: No retention period specified (CHANGELOG_RETENTION = {settings.CHANGELOG_RETENTION})"
|
||||
)
|
||||
|
||||
# Check for new releases (if enabled)
|
||||
self.stdout.write("[*] Checking for latest release")
|
||||
if options['verbosity']:
|
||||
self.stdout.write("[*] Checking for latest release")
|
||||
if settings.RELEASE_CHECK_URL:
|
||||
headers = {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
}
|
||||
|
||||
try:
|
||||
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tFetching {settings.RELEASE_CHECK_URL}")
|
||||
response = requests.get(
|
||||
url=settings.RELEASE_CHECK_URL,
|
||||
headers=headers,
|
||||
@ -73,15 +85,19 @@ class Command(BaseCommand):
|
||||
continue
|
||||
releases.append((version.parse(release['tag_name']), release.get('html_url')))
|
||||
latest_release = max(releases)
|
||||
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
|
||||
self.stdout.write(f"\tLatest release: {latest_release[0]}")
|
||||
if options['verbosity'] >= 2:
|
||||
self.stdout.write(f"\tFound {len(response.json())} releases; {len(releases)} usable")
|
||||
if options['verbosity']:
|
||||
self.stdout.write(f"\tLatest release: {latest_release[0]}", self.style.SUCCESS)
|
||||
|
||||
# Cache the most recent release
|
||||
cache.set('latest_release', latest_release, None)
|
||||
|
||||
except requests.exceptions.RequestException as exc:
|
||||
self.stdout.write(f"\tRequest error: {exc}")
|
||||
self.stdout.write(f"\tRequest error: {exc}", self.style.ERROR)
|
||||
else:
|
||||
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
|
||||
if options['verbosity']:
|
||||
self.stdout.write(f"\tSkipping: RELEASE_CHECK_URL not set")
|
||||
|
||||
self.stdout.write("Finished.", self.style.SUCCESS)
|
||||
if options['verbosity']:
|
||||
self.stdout.write("Finished.", self.style.SUCCESS)
|
||||
|
@ -470,7 +470,6 @@ def get_scripts(use_names=False):
|
||||
defined name in place of the actual module name.
|
||||
"""
|
||||
scripts = OrderedDict()
|
||||
|
||||
# Iterate through all modules within the reports path. These are the user-created files in which reports are
|
||||
# defined.
|
||||
for importer, module_name, _ in pkgutil.iter_modules([settings.SCRIPTS_ROOT]):
|
||||
@ -478,8 +477,11 @@ def get_scripts(use_names=False):
|
||||
if use_names and hasattr(module, 'name'):
|
||||
module_name = module.name
|
||||
module_scripts = OrderedDict()
|
||||
for name, cls in inspect.getmembers(module, is_script):
|
||||
module_scripts[name] = cls
|
||||
script_order = getattr(module, "script_order", ())
|
||||
ordered_scripts = [cls for cls in script_order if is_script(cls)]
|
||||
unordered_scripts = [cls for _, cls in inspect.getmembers(module, is_script) if cls not in script_order]
|
||||
for cls in [*ordered_scripts, *unordered_scripts]:
|
||||
module_scripts[cls.__name__] = cls
|
||||
if module_scripts:
|
||||
scripts[module_name] = module_scripts
|
||||
|
||||
|
@ -78,6 +78,7 @@ urlpatterns = [
|
||||
kwargs={'model': models.ConfigContext}),
|
||||
|
||||
# Image attachments
|
||||
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
|
||||
path('image-attachments/<int:pk>/edit/', views.ImageAttachmentEditView.as_view(), name='imageattachment_edit'),
|
||||
path('image-attachments/<int:pk>/delete/', views.ImageAttachmentDeleteView.as_view(), name='imageattachment_delete'),
|
||||
|
||||
|
@ -472,22 +472,26 @@ class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
model_form = forms.ImageAttachmentForm
|
||||
|
||||
def alter_obj(self, imageattachment, request, args, kwargs):
|
||||
if not imageattachment.pk:
|
||||
def alter_obj(self, instance, request, args, kwargs):
|
||||
if not instance.pk:
|
||||
# Assign the parent object based on URL kwargs
|
||||
model = kwargs.get('model')
|
||||
imageattachment.parent = get_object_or_404(model, pk=kwargs['object_id'])
|
||||
return imageattachment
|
||||
try:
|
||||
app_label, model = request.GET.get('content_type').split('.')
|
||||
except (AttributeError, ValueError):
|
||||
raise Http404("Content type not specified")
|
||||
content_type = get_object_or_404(ContentType, app_label=app_label, model=model)
|
||||
instance.parent = get_object_or_404(content_type.model_class(), pk=request.GET.get('object_id'))
|
||||
return instance
|
||||
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
def get_return_url(self, request, obj=None):
|
||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
||||
|
||||
|
||||
class ImageAttachmentDeleteView(generic.ObjectDeleteView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
|
||||
def get_return_url(self, request, imageattachment):
|
||||
return imageattachment.parent.get_absolute_url()
|
||||
def get_return_url(self, request, obj=None):
|
||||
return obj.parent.get_absolute_url() if obj else super().get_return_url(request)
|
||||
|
||||
|
||||
#
|
||||
|
@ -39,15 +39,7 @@ PREFIXFLAT_LINK = """
|
||||
{% if record.pk %}
|
||||
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
PREFIX_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{{ record.prefix }}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@ -218,8 +210,8 @@ class PrefixTable(BaseTable):
|
||||
linkify=True,
|
||||
verbose_name='VLAN'
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=PREFIX_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
is_pool = BooleanColumn(
|
||||
verbose_name='Pool'
|
||||
@ -264,8 +256,8 @@ class IPRangeTable(BaseTable):
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=PREFIX_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
|
||||
|
@ -6,7 +6,7 @@ from dcim.models import Interface
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import (
|
||||
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ContentTypeColumn, LinkedCountColumn, TagColumn,
|
||||
ToggleColumn,
|
||||
TemplateColumn, ToggleColumn,
|
||||
)
|
||||
from virtualization.models import VMInterface
|
||||
from ipam.models import *
|
||||
@ -35,19 +35,9 @@ VLAN_LINK = """
|
||||
VLAN_PREFIXES = """
|
||||
{% for prefix in record.prefixes.all %}
|
||||
<a href="{% url 'ipam:prefix' pk=prefix.pk %}">{{ prefix }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
VLAN_ROLE_LINK = """
|
||||
{% if record.role %}
|
||||
<a href="{% url 'ipam:vlan_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
VLANGROUP_ADD_VLAN = """
|
||||
{% with next_vid=record.get_next_available_vid %}
|
||||
{% if next_vid and perms.ipam.add_vlan %}
|
||||
@ -115,10 +105,10 @@ class VLANTable(BaseTable):
|
||||
status = ChoiceFieldColumn(
|
||||
default=AVAILABLE_LABEL
|
||||
)
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
prefixes = tables.TemplateColumn(
|
||||
prefixes = TemplateColumn(
|
||||
template_code=VLAN_PREFIXES,
|
||||
orderable=False,
|
||||
verbose_name='Prefixes'
|
||||
@ -190,8 +180,8 @@ class InterfaceVLANTable(BaseTable):
|
||||
)
|
||||
tenant = TenantColumn()
|
||||
status = ChoiceFieldColumn()
|
||||
role = tables.TemplateColumn(
|
||||
template_code=VLAN_ROLE_LINK
|
||||
role = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
|
||||
class Meta(BaseTable.Meta):
|
||||
|
@ -1,7 +1,7 @@
|
||||
import django_tables2 as tables
|
||||
|
||||
from tenancy.tables import TenantColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, TagColumn, ToggleColumn
|
||||
from utilities.tables import BaseTable, BooleanColumn, TagColumn, TemplateColumn, ToggleColumn
|
||||
from ipam.models import *
|
||||
|
||||
__all__ = (
|
||||
@ -11,9 +11,7 @@ __all__ = (
|
||||
|
||||
VRF_TARGETS = """
|
||||
{% for rt in value.all %}
|
||||
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% empty %}
|
||||
—
|
||||
<a href="{{ rt.get_absolute_url }}">{{ rt }}</a>{% if not forloop.last %}<br />{% endif %}
|
||||
{% endfor %}
|
||||
"""
|
||||
|
||||
@ -34,11 +32,11 @@ class VRFTable(BaseTable):
|
||||
enforce_unique = BooleanColumn(
|
||||
verbose_name='Unique'
|
||||
)
|
||||
import_targets = tables.TemplateColumn(
|
||||
import_targets = TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
export_targets = tables.TemplateColumn(
|
||||
export_targets = TemplateColumn(
|
||||
template_code=VRF_TARGETS,
|
||||
orderable=False
|
||||
)
|
||||
|
@ -240,6 +240,7 @@ class AggregateView(generic.ObjectView):
|
||||
return {
|
||||
'prefix_table': prefix_table,
|
||||
'permissions': permissions,
|
||||
'bulk_querystring': f'within={instance.prefix}',
|
||||
'show_available': request.GET.get('show_available', 'true') == 'true',
|
||||
}
|
||||
|
||||
|
@ -230,7 +230,7 @@ class ModelViewSet(BulkUpdateModelMixin, BulkDestroyModelMixin, ModelViewSet_):
|
||||
Overrides ListModelMixin to allow processing ExportTemplates.
|
||||
"""
|
||||
if 'export' in request.GET:
|
||||
content_type = ContentType.objects.get_for_model(self.serializer_class.Meta.model)
|
||||
content_type = ContentType.objects.get_for_model(self.get_serializer_class().Meta.model)
|
||||
et = get_object_or_404(ExportTemplate, content_type=content_type, name=request.GET['export'])
|
||||
queryset = self.filter_queryset(self.get_queryset())
|
||||
return et.render_to_response(queryset)
|
||||
|
@ -16,7 +16,7 @@ from django.core.validators import URLValidator
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.0.5-dev'
|
||||
VERSION = '3.0.6-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
|
@ -17,7 +17,7 @@ from .admin import admin_site
|
||||
|
||||
openapi_info = openapi.Info(
|
||||
title="NetBox API",
|
||||
default_version='v2',
|
||||
default_version='v3',
|
||||
description="API to access NetBox",
|
||||
terms_of_service="https://github.com/netbox-community/netbox",
|
||||
license=openapi.License(name="Apache v2 License"),
|
||||
@ -59,9 +59,9 @@ _patterns = [
|
||||
path('api/users/', include('users.api.urls')),
|
||||
path('api/virtualization/', include('virtualization.api.urls')),
|
||||
path('api/status/', StatusView.as_view(), name='api-status'),
|
||||
path('api/docs/', schema_view.with_ui('swagger'), name='api_docs'),
|
||||
path('api/redoc/', schema_view.with_ui('redoc'), name='api_redocs'),
|
||||
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(), name='schema_swagger'),
|
||||
path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
|
||||
path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
|
||||
re_path(r'^api/swagger(?P<format>.json|.yaml)$', schema_view.without_ui(cache_timeout=86400), name='schema_swagger'),
|
||||
|
||||
# GraphQL
|
||||
path('graphql/', csrf_exempt(GraphQLView.as_view(graphiql=True, schema=schema)), name='graphql'),
|
||||
|
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -36,7 +36,7 @@ function handleSelectAllToggle(event: Event): void {
|
||||
|
||||
if (table !== null) {
|
||||
for (const element of table.querySelectorAll<HTMLInputElement>(
|
||||
'input[type="checkbox"][name="pk"]',
|
||||
'tr:not(.d-none) input[type="checkbox"][name="pk"]',
|
||||
)) {
|
||||
if (tableSelectAll.checked) {
|
||||
// Check all PK checkboxes if the select all checkbox is checked.
|
||||
|
@ -6,11 +6,15 @@
|
||||
lang="en"
|
||||
data-netbox-url-name="{{ request.resolver_match.url_name }}"
|
||||
data-netbox-base-path="{{ settings.BASE_PATH }}"
|
||||
{% if preferences|get_key:'ui.colormode' == 'dark'%}
|
||||
data-netbox-color-mode="dark"
|
||||
{% else %}
|
||||
data-netbox-color-mode="light"
|
||||
{% endif %}
|
||||
{% with preferences|get_key:'ui.colormode' as color_mode %}
|
||||
{% if color_mode == 'dark'%}
|
||||
data-netbox-color-mode="dark"
|
||||
{% elif color_mode == 'light' %}
|
||||
data-netbox-color-mode="light"
|
||||
{% else %}
|
||||
data-netbox-color-mode="unset"
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
@ -23,34 +27,55 @@
|
||||
<title>{% block title %}Home{% endblock %} | NetBox</title>
|
||||
|
||||
<script type="text/javascript">
|
||||
/**
|
||||
* Determine the best initial color mode to use prior to rendering.
|
||||
*/
|
||||
(function() {
|
||||
// Browser prefers dark color scheme.
|
||||
var preferDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
// Browser prefers light color scheme.
|
||||
var preferLight = window.matchMedia('(prefers-color-scheme: light)').matches;
|
||||
// Client NetBox color-mode override.
|
||||
var clientMode = localStorage.getItem('netbox-color-mode');
|
||||
// NetBox server-rendered value.
|
||||
var serverMode = document.documentElement.getAttribute('data-netbox-color-mode');
|
||||
/**
|
||||
* Set the color mode on the `<html/>` element and in local storage.
|
||||
*/
|
||||
function setMode(mode) {
|
||||
document.documentElement.setAttribute("data-netbox-color-mode", mode);
|
||||
localStorage.setItem("netbox-color-mode", mode);
|
||||
}
|
||||
/**
|
||||
* Determine the best initial color mode to use prior to rendering.
|
||||
*/
|
||||
(function () {
|
||||
try {
|
||||
// Browser prefers dark color scheme.
|
||||
var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
// Browser prefers light color scheme.
|
||||
var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
|
||||
// Client NetBox color-mode override.
|
||||
var clientMode = localStorage.getItem("netbox-color-mode");
|
||||
// NetBox server-rendered value.
|
||||
var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
|
||||
|
||||
if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
|
||||
// If the client mode is not set but the server mode is, use the server mode.
|
||||
return setMode(serverMode);
|
||||
}
|
||||
if (clientMode !== null && clientMode !== serverMode) {
|
||||
// If the client mode is set and is different than the server mode, use the client mode
|
||||
// over the server mode, as it should be more recent.
|
||||
return setMode(clientMode);
|
||||
}
|
||||
if (clientMode === serverMode) {
|
||||
// If the client and server modes match, use that value.
|
||||
return setMode(clientMode);
|
||||
}
|
||||
if (preferDark && serverMode === "unset") {
|
||||
// If the server mode is not set but the browser prefers dark mode, use dark mode.
|
||||
return setMode("dark");
|
||||
}
|
||||
if (preferLight && serverMode === "unset") {
|
||||
// If the server mode is not set but the browser prefers light mode, use light mode.
|
||||
return setMode("light");
|
||||
}
|
||||
} catch (error) {
|
||||
// In the event of an error, log it to the console and set the mode to light mode.
|
||||
console.error(error);
|
||||
}
|
||||
return setMode("light");
|
||||
})();
|
||||
|
||||
if ((clientMode !== null) && (clientMode !== serverMode)) {
|
||||
// If the client mode is set, use its value over the server's value.
|
||||
return document.documentElement.setAttribute('data-netbox-color-mode', clientMode);
|
||||
}
|
||||
if (preferDark && serverMode === 'light') {
|
||||
// If the client value matches the server value, the browser preferrs dark-mode, but
|
||||
// the server value doesn't match the browser preference, use dark mode.
|
||||
return document.documentElement.setAttribute('data-netbox-color-mode', 'dark');
|
||||
}
|
||||
if (preferLight && serverMode === 'dark') {
|
||||
// If the client value matches the server value, the browser preferrs dark-mode, but
|
||||
// the server value doesn't match the browser preference, use light mode.
|
||||
return document.documentElement.setAttribute('data-netbox-color-mode', 'light');
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
|
||||
{# Static resources #}
|
||||
|
@ -72,6 +72,7 @@
|
||||
<div class="col col-md-6">
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
|
||||
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -290,22 +290,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:device_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
Attach an Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
<div class="card noprint">
|
||||
<h5 class="card-header">
|
||||
Related Devices
|
||||
|
@ -59,22 +59,7 @@
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:location_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span>
|
||||
Attach an Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -39,11 +39,12 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
|
||||
{% plugin_left_page object %}
|
||||
</div>
|
||||
<div class="col col-md-6">
|
||||
{% include 'inc/custom_fields_panel.html' %}
|
||||
{% include 'extras/inc/tags_panel.html' with tags=object.tags.all url='dcim:powerpanel_list' %}
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -210,22 +210,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:rack_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Attach an Image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Reservations
|
||||
|
@ -30,7 +30,7 @@
|
||||
{% if page %}
|
||||
<div style="white-space: nowrap; overflow-x: scroll;">
|
||||
{% for rack in page %}
|
||||
<div style="display: inline-block; margin-right: 12px; width: 254px">
|
||||
<div style="display: inline-block; margin-right: 12px">
|
||||
<div style="margin-left: 30px">
|
||||
<div class="text-center">
|
||||
<strong><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack.name }}</a></strong>
|
||||
|
@ -242,22 +242,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/image_attachments.html' with images=object.images.all %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'dcim:site_add_image' object_id=object.pk %}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
Attach an image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% include 'inc/image_attachments_panel.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -4,25 +4,19 @@
|
||||
{% block title %}Add New Member to Virtual Chassis {{ virtual_chassis }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form form-horizontal">
|
||||
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit">
|
||||
{% csrf_token %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6">
|
||||
<div class="card">
|
||||
<h5 class="card-header">Add New Member</h5>
|
||||
<div class="card-body">
|
||||
{% render_form member_select_form %}
|
||||
{% render_form membership_form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h5 class="card-header">Add New Member</h5>
|
||||
<div class="card-body">
|
||||
{% render_form member_select_form %}
|
||||
{% render_form membership_form %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-6 text-end">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
|
||||
<button type="submit" name="_addanother" class="btn btn-outline-primary">Add Another</button>
|
||||
<button type="submit" name="_save" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
<div class="text-end my-3">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
|
||||
<button type="submit" name="_addanother" class="btn btn-outline-primary">Add Another</button>
|
||||
<button type="submit" name="_save" class="btn btn-primary">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
|
@ -1,38 +0,0 @@
|
||||
{% load helpers %}
|
||||
|
||||
{% if images %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for attachment in images %}
|
||||
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
|
||||
<td>
|
||||
<i class="mdi mdi-file-image-outline"></i>
|
||||
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
</td>
|
||||
<td>{{ attachment.size|filesizeformat }}</td>
|
||||
<td>{{ attachment.created|annotated_date }}</td>
|
||||
<td class="text-end noprint">
|
||||
{% if perms.extras.change_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">
|
||||
None
|
||||
</div>
|
||||
{% endif %}
|
52
netbox/templates/inc/image_attachments_panel.html
Normal file
52
netbox/templates/inc/image_attachments_panel.html
Normal file
@ -0,0 +1,52 @@
|
||||
{% load helpers %}
|
||||
|
||||
<div class="card">
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% with images=object.images.all %}
|
||||
{% if images.exists %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for attachment in images %}
|
||||
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
|
||||
<td>
|
||||
<i class="mdi mdi-file-image-outline"></i>
|
||||
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
</td>
|
||||
<td>{{ attachment.size|filesizeformat }}</td>
|
||||
<td>{{ attachment.created|annotated_date }}</td>
|
||||
<td class="text-end noprint">
|
||||
{% if perms.extras.change_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">None</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Attach an image
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
@ -75,8 +75,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mb-3">
|
||||
<div class="col col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
|
||||
</div>
|
||||
<div class="col col-md-12">
|
||||
{% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -25,11 +25,9 @@
|
||||
Child Ranges {% badge object.get_child_ranges.count %}
|
||||
</a>
|
||||
</li>
|
||||
{% if perms.ipam.view_ipaddress and object.status != 'container' %}
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">
|
||||
IP Addresses {% badge object.get_child_ips.count %}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li role="presentation" class="nav-item">
|
||||
<a class="nav-link{% if active_tab == 'ip-addresses' %} active{% endif %}" href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">
|
||||
IP Addresses {% badge object.get_child_ips.count %}
|
||||
</a>
|
||||
</li>
|
||||
{% endblock %}
|
||||
|
@ -24,7 +24,7 @@
|
||||
<div class="form-check">
|
||||
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
|
||||
<label for="select-all" class="form-check-label">
|
||||
Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
|
||||
Select <strong>all {{ table.objects_count }} {{ table.data.verbose_name_plural }}</strong> matching query
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -29,13 +29,18 @@ class BaseTable(tables.Table):
|
||||
'class': 'table table-hover object-list',
|
||||
}
|
||||
|
||||
def __init__(self, *args, user=None, **kwargs):
|
||||
def __init__(self, *args, user=None, extra_columns=None, **kwargs):
|
||||
# Add custom field columns
|
||||
obj_type = ContentType.objects.get_for_model(self._meta.model)
|
||||
for cf in CustomField.objects.filter(content_types=obj_type):
|
||||
self.base_columns[f'cf_{cf.name}'] = CustomFieldColumn(cf)
|
||||
cf_columns = [
|
||||
(f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
|
||||
]
|
||||
if extra_columns is not None:
|
||||
extra_columns.extend(cf_columns)
|
||||
else:
|
||||
extra_columns = cf_columns
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
||||
|
||||
# Set default empty_text if none was provided
|
||||
if self.empty_text is None:
|
||||
@ -50,17 +55,22 @@ class BaseTable(tables.Table):
|
||||
|
||||
# Apply custom column ordering for user
|
||||
if user is not None and not isinstance(user, AnonymousUser):
|
||||
columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
|
||||
if columns:
|
||||
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns")
|
||||
if selected_columns:
|
||||
pk = self.base_columns.pop('pk', None)
|
||||
actions = self.base_columns.pop('actions', None)
|
||||
|
||||
for name, column in self.base_columns.items():
|
||||
if name in columns:
|
||||
for name, column in self.columns.items():
|
||||
if name in selected_columns:
|
||||
self.columns.show(name)
|
||||
else:
|
||||
self.columns.hide(name)
|
||||
self.sequence = [c for c in columns if c in self.base_columns]
|
||||
# Rearrange the sequence to list selected columns first, followed by all remaining columns
|
||||
# TODO: There's probably a more clever way to accomplish this
|
||||
self.sequence = [
|
||||
*[c for c in selected_columns if c in self.columns.names()],
|
||||
*[c for c in self.columns.names() if c not in selected_columns]
|
||||
]
|
||||
|
||||
# Always include PK and actions column, if defined on the table
|
||||
if pk:
|
||||
@ -111,6 +121,16 @@ class BaseTable(tables.Table):
|
||||
def selected_columns(self):
|
||||
return self._get_columns(visible=True)
|
||||
|
||||
@property
|
||||
def objects_count(self):
|
||||
"""
|
||||
Return the total number of real objects represented by the Table. This is useful when dealing with
|
||||
prefixes/IP addresses/etc., where some table rows may represent available address space.
|
||||
"""
|
||||
if not hasattr(self, '_objects_count'):
|
||||
self._objects_count = sum(1 for obj in self.data if getattr(obj, 'pk'))
|
||||
return self._objects_count
|
||||
|
||||
|
||||
#
|
||||
# Table columns
|
||||
@ -157,6 +177,25 @@ class BooleanColumn(tables.Column):
|
||||
return str(value)
|
||||
|
||||
|
||||
class TemplateColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Overrides the stock TemplateColumn to render a placeholder if the returned value is an empty string.
|
||||
"""
|
||||
PLACEHOLDER = mark_safe('—')
|
||||
|
||||
def render(self, *args, **kwargs):
|
||||
ret = super().render(*args, **kwargs)
|
||||
if not ret.strip():
|
||||
return self.PLACEHOLDER
|
||||
return ret
|
||||
|
||||
def value(self, **kwargs):
|
||||
ret = super().value(**kwargs)
|
||||
if ret == self.PLACEHOLDER:
|
||||
return ''
|
||||
return ret
|
||||
|
||||
|
||||
class ButtonsColumn(tables.TemplateColumn):
|
||||
"""
|
||||
Render edit, delete, and changelog buttons for an object.
|
||||
|
@ -398,6 +398,9 @@ def applied_filters(form, query_params):
|
||||
|
||||
applied_filters = []
|
||||
for filter_name in form.changed_data:
|
||||
if filter_name not in form.cleaned_data:
|
||||
continue
|
||||
|
||||
querydict = query_params.copy()
|
||||
if filter_name not in querydict:
|
||||
continue
|
||||
|
@ -18,10 +18,13 @@ gunicorn==20.1.0
|
||||
Jinja2==3.0.1
|
||||
Markdown==3.3.4
|
||||
markdown-include==0.6.0
|
||||
mkdocs-material==7.3.0
|
||||
mkdocs-material==7.3.1
|
||||
netaddr==0.8.0
|
||||
Pillow==8.3.2
|
||||
psycopg2-binary==2.9.1
|
||||
PyYAML==5.4.1
|
||||
svgwrite==1.4.1
|
||||
tablib==3.0.0
|
||||
|
||||
# Workaround for #7401
|
||||
jsonschema==3.2.0
|
||||
|
16
upgrade.sh
16
upgrade.sh
@ -61,22 +61,6 @@ else
|
||||
echo "Skipping local dependencies (local_requirements.txt not found)"
|
||||
fi
|
||||
|
||||
# Test schema migrations integrity
|
||||
COMMAND="python3 netbox/manage.py showmigrations"
|
||||
eval $COMMAND > /dev/null 2>&1 || {
|
||||
echo "--------------------------------------------------------------------"
|
||||
echo "ERROR: Database schema migrations are out of synchronization. (No"
|
||||
echo "data has been lost.) If attempting to upgrade to NetBox v3.0 or"
|
||||
echo "later, first upgrade to a v2.11 release to ensure schema migrations"
|
||||
echo "have been correctly prepared. For further detail on the exact error,"
|
||||
echo "run the following commands:"
|
||||
echo ""
|
||||
echo " source ${VIRTUALENV}/bin/activate"
|
||||
echo " ${COMMAND}"
|
||||
echo "--------------------------------------------------------------------"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Apply any database migrations
|
||||
COMMAND="python3 netbox/manage.py migrate"
|
||||
echo "Applying database migrations ($COMMAND)..."
|
||||
|
Loading…
Reference in New Issue
Block a user