mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
commit
90f91eeea4
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.1.7
|
placeholder: v3.1.8
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.1.7
|
placeholder: v3.1.8
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@ -38,6 +38,19 @@ jobs:
|
|||||||
uses: actions/setup-node@v2
|
uses: actions/setup-node@v2
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
|
- name: Install Yarn Package Manager
|
||||||
|
run: npm install -g yarn
|
||||||
|
|
||||||
|
- name: Setup Node.js with Yarn Caching
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: ${{ matrix.node-version }}
|
||||||
|
cache: yarn
|
||||||
|
cache-dependency-path: netbox/project-static/yarn.lock
|
||||||
|
|
||||||
|
- name: Install Frontend Dependencies
|
||||||
|
run: yarn --cwd netbox/project-static
|
||||||
|
|
||||||
- name: Install dependencies & set up configuration
|
- name: Install dependencies & set up configuration
|
||||||
run: |
|
run: |
|
||||||
@ -45,7 +58,6 @@ jobs:
|
|||||||
pip install -r requirements.txt
|
pip install -r requirements.txt
|
||||||
pip install pycodestyle coverage
|
pip install pycodestyle coverage
|
||||||
ln -s configuration.testing.py netbox/netbox/configuration.py
|
ln -s configuration.testing.py netbox/netbox/configuration.py
|
||||||
yarn --cwd netbox/project-static
|
|
||||||
|
|
||||||
- name: Build documentation
|
- name: Build documentation
|
||||||
run: mkdocs build
|
run: mkdocs build
|
||||||
@ -63,7 +75,7 @@ jobs:
|
|||||||
run: scripts/verify-bundles.sh
|
run: scripts/verify-bundles.sh
|
||||||
|
|
||||||
- name: Run tests
|
- name: Run tests
|
||||||
run: coverage run --source="netbox/" netbox/manage.py test netbox/
|
run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
|
||||||
|
|
||||||
- name: Show coverage report
|
- name: Show coverage report
|
||||||
run: coverage report --skip-covered --omit *migrations*
|
run: coverage report --skip-covered --omit *migrations*
|
||||||
|
@ -16,13 +16,6 @@ categories for discussions:
|
|||||||
feature request
|
feature request
|
||||||
* **Q&A** - Request help with installing or using NetBox
|
* **Q&A** - Request help with installing or using NetBox
|
||||||
|
|
||||||
### Mailing List
|
|
||||||
|
|
||||||
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
|
|
||||||
for general discussion, however we're encouraging people to use GitHub
|
|
||||||
discussions where possible, as it's much easier for newcomers to review past
|
|
||||||
discussions.
|
|
||||||
|
|
||||||
### Slack
|
### Slack
|
||||||
|
|
||||||
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
|
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).
|
||||||
|
@ -68,7 +68,6 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
|
|||||||
|
|
||||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
|
||||||
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
|
||||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
|
|
||||||
|
|
||||||
### Installation
|
### Installation
|
||||||
|
|
||||||
|
@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
|
|||||||
|
|
||||||
Default: `{}` (Empty dictionary)
|
Default: `{}` (Empty dictionary)
|
||||||
|
|
||||||
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.)
|
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
|
|||||||
|
|
||||||
Default: `False`
|
Default: `False`
|
||||||
|
|
||||||
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.)
|
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -7,9 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
|
|||||||
There are several official forums for communication among the developers and community members:
|
There are several official forums for communication among the developers and community members:
|
||||||
|
|
||||||
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
|
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
|
||||||
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
* [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
|
||||||
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
|
||||||
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
|
|
||||||
|
|
||||||
## Governance
|
## Governance
|
||||||
|
|
||||||
|
@ -11,10 +11,6 @@ The following sections detail how to set up a new instance of NetBox:
|
|||||||
5. [HTTP server](5-http-server.md)
|
5. [HTTP server](5-http-server.md)
|
||||||
6. [LDAP authentication](6-ldap.md) (optional)
|
6. [LDAP authentication](6-ldap.md) (optional)
|
||||||
|
|
||||||
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>
|
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
| Dependency | Minimum Version |
|
| Dependency | Minimum Version |
|
||||||
|
@ -1,5 +1,29 @@
|
|||||||
# NetBox v3.1
|
# NetBox v3.1
|
||||||
|
|
||||||
|
## v3.1.8 (2022-02-15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
|
||||||
|
* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
|
||||||
|
* [#8556](https://github.com/netbox-community/netbox/issues/8556) - Add full username column to changelog table
|
||||||
|
* [#8620](https://github.com/netbox-community/netbox/issues/8620) - Enable tab completion for `nbshell`
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
|
||||||
|
* [#8391](https://github.com/netbox-community/netbox/issues/8391) - Null date columns should return empty strings during CSV export
|
||||||
|
* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
|
||||||
|
* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
|
||||||
|
* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
|
||||||
|
* [#8577](https://github.com/netbox-community/netbox/issues/8577) - Show contact assignment counts in global search results
|
||||||
|
* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
|
||||||
|
* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
|
||||||
|
* [#8609](https://github.com/netbox-community/netbox/issues/8609) - Display validation error when attempting to assign VLANs to interface with no mode during bulk edit
|
||||||
|
* [#8611](https://github.com/netbox-community/netbox/issues/8611) - Fix bulk editing for certain custom link, webhook, and journal entry fields
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v3.1.7 (2022-02-03)
|
## v3.1.7 (2022-02-03)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -1043,8 +1043,14 @@ class InterfaceBulkEditForm(
|
|||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
|
||||||
|
if not self.cleaned_data['mode']:
|
||||||
|
if self.cleaned_data['untagged_vlan']:
|
||||||
|
raise forms.ValidationError({'untagged_vlan': "Interface mode must be specified to assign VLANs"})
|
||||||
|
elif self.cleaned_data['tagged_vlans']:
|
||||||
|
raise forms.ValidationError({'tagged_vlans': "Interface mode must be specified to assign VLANs"})
|
||||||
|
|
||||||
# Untagged interfaces cannot be assigned tagged VLANs
|
# Untagged interfaces cannot be assigned tagged VLANs
|
||||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
|
||||||
raise forms.ValidationError({
|
raise forms.ValidationError({
|
||||||
'mode': "An access interface cannot have tagged VLANs assigned."
|
'mode': "An access interface cannot have tagged VLANs assigned."
|
||||||
})
|
})
|
||||||
|
@ -126,10 +126,16 @@ class RackElevationSVG:
|
|||||||
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
|
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
|
||||||
|
|
||||||
def _draw_device_rear(self, drawing, device, start, end, text):
|
def _draw_device_rear(self, drawing, device, start, end, text):
|
||||||
rect = drawing.rect(start, end, class_="slot blocked")
|
link = drawing.add(
|
||||||
rect.set_desc(self._get_device_description(device))
|
drawing.a(
|
||||||
drawing.add(rect)
|
href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
|
||||||
drawing.add(drawing.text(get_device_name(device), insert=text))
|
target='_top',
|
||||||
|
fill='black'
|
||||||
|
)
|
||||||
|
)
|
||||||
|
link.set_desc(self._get_device_description(device))
|
||||||
|
link.add(drawing.rect(start, end, class_="slot blocked"))
|
||||||
|
link.add(drawing.text(get_device_name(device), insert=text))
|
||||||
|
|
||||||
# Embed rear device type image if one exists
|
# Embed rear device type image if one exists
|
||||||
if self.include_images and device.device_type.rear_image:
|
if self.include_images and device.device_type.rear_image:
|
||||||
|
@ -298,6 +298,8 @@ REARPORT_BUTTONS = """
|
|||||||
</button>
|
</button>
|
||||||
<ul class="dropdown-menu dropdown-menu-end">
|
<ul class="dropdown-menu dropdown-menu-end">
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>
|
||||||
|
@ -317,6 +317,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
to_field_name='slug',
|
to_field_name='slug',
|
||||||
label='Tenant (slug)',
|
label='Tenant (slug)',
|
||||||
)
|
)
|
||||||
|
tag_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='tags',
|
||||||
|
queryset=Tag.objects.all(),
|
||||||
|
label='Tag',
|
||||||
|
)
|
||||||
tag = django_filters.ModelMultipleChoiceFilter(
|
tag = django_filters.ModelMultipleChoiceFilter(
|
||||||
field_name='tags__slug',
|
field_name='tags__slug',
|
||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
|
@ -4,7 +4,9 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
from extras.utils import FeatureQuery
|
from extras.utils import FeatureQuery
|
||||||
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect
|
from utilities.forms import (
|
||||||
|
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ConfigContextBulkEditForm',
|
'ConfigContextBulkEditForm',
|
||||||
@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
button_class = forms.ChoiceField(
|
button_class = forms.ChoiceField(
|
||||||
choices=CustomLinkButtonClassChoices,
|
choices=add_blank_choice(CustomLinkButtonClassChoices),
|
||||||
required=False,
|
required=False,
|
||||||
widget=StaticSelect()
|
widget=StaticSelect()
|
||||||
)
|
)
|
||||||
@ -117,21 +119,25 @@ class WebhookBulkEditForm(BulkEditForm):
|
|||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect()
|
||||||
)
|
)
|
||||||
http_method = forms.ChoiceField(
|
http_method = forms.ChoiceField(
|
||||||
choices=WebhookHttpMethodChoices,
|
choices=add_blank_choice(WebhookHttpMethodChoices),
|
||||||
required=False
|
required=False,
|
||||||
|
label='HTTP method'
|
||||||
)
|
)
|
||||||
payload_url = forms.CharField(
|
payload_url = forms.CharField(
|
||||||
required=False
|
required=False,
|
||||||
|
label='Payload URL'
|
||||||
)
|
)
|
||||||
ssl_verification = forms.NullBooleanField(
|
ssl_verification = forms.NullBooleanField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=BulkEditNullBooleanSelect()
|
widget=BulkEditNullBooleanSelect(),
|
||||||
|
label='SSL verification'
|
||||||
)
|
)
|
||||||
secret = forms.CharField(
|
secret = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
ca_file_path = forms.CharField(
|
ca_file_path = forms.CharField(
|
||||||
required=False
|
required=False,
|
||||||
|
label='CA file path'
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -185,7 +191,7 @@ class JournalEntryBulkEditForm(BulkEditForm):
|
|||||||
widget=forms.MultipleHiddenInput
|
widget=forms.MultipleHiddenInput
|
||||||
)
|
)
|
||||||
kind = forms.ChoiceField(
|
kind = forms.ChoiceField(
|
||||||
choices=JournalEntryKindChoices,
|
choices=add_blank_choice(JournalEntryKindChoices),
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
comments = forms.CharField(
|
comments = forms.CharField(
|
||||||
|
@ -155,7 +155,7 @@ class TagFilterForm(FilterForm):
|
|||||||
|
|
||||||
class ConfigContextFilterForm(FilterForm):
|
class ConfigContextFilterForm(FilterForm):
|
||||||
field_groups = [
|
field_groups = [
|
||||||
['q', 'tag'],
|
['q', 'tag_id'],
|
||||||
['region_id', 'site_group_id', 'site_id'],
|
['region_id', 'site_group_id', 'site_id'],
|
||||||
['device_type_id', 'platform_id', 'role_id'],
|
['device_type_id', 'platform_id', 'role_id'],
|
||||||
['cluster_group_id', 'cluster_id'],
|
['cluster_group_id', 'cluster_id'],
|
||||||
@ -211,9 +211,8 @@ class ConfigContextFilterForm(FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('Tenant')
|
label=_('Tenant')
|
||||||
)
|
)
|
||||||
tag = DynamicModelMultipleChoiceField(
|
tag_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Tag.objects.all(),
|
queryset=Tag.objects.all(),
|
||||||
to_field_name='slug',
|
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Tags')
|
label=_('Tags')
|
||||||
)
|
)
|
||||||
|
@ -70,10 +70,23 @@ class Command(BaseCommand):
|
|||||||
return namespace
|
return namespace
|
||||||
|
|
||||||
def handle(self, **options):
|
def handle(self, **options):
|
||||||
|
namespace = self.get_namespace()
|
||||||
|
|
||||||
# If Python code has been passed, execute it and exit.
|
# If Python code has been passed, execute it and exit.
|
||||||
if options['command']:
|
if options['command']:
|
||||||
exec(options['command'], self.get_namespace())
|
exec(options['command'], namespace)
|
||||||
return
|
return
|
||||||
|
|
||||||
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace())
|
# Try to enable tab-complete
|
||||||
|
try:
|
||||||
|
import readline
|
||||||
|
import rlcompleter
|
||||||
|
except ModuleNotFoundError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
readline.set_completer(rlcompleter.Completer(namespace).complete)
|
||||||
|
readline.parse_and_bind('tab: complete')
|
||||||
|
|
||||||
|
# Run interactive shell
|
||||||
|
shell = code.interact(banner=BANNER_TEXT, local=namespace)
|
||||||
return shell
|
return shell
|
||||||
|
@ -29,6 +29,11 @@ CONFIGCONTEXT_ACTIONS = """
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
OBJECTCHANGE_FULL_NAME = """
|
||||||
|
{% load helpers %}
|
||||||
|
{{ record.user.get_full_name|placeholder }}
|
||||||
|
"""
|
||||||
|
|
||||||
OBJECTCHANGE_OBJECT = """
|
OBJECTCHANGE_OBJECT = """
|
||||||
{% if record.changed_object and record.changed_object.get_absolute_url %}
|
{% if record.changed_object and record.changed_object.get_absolute_url %}
|
||||||
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
|
||||||
@ -204,6 +209,14 @@ class ObjectChangeTable(BaseTable):
|
|||||||
linkify=True,
|
linkify=True,
|
||||||
format=settings.SHORT_DATETIME_FORMAT
|
format=settings.SHORT_DATETIME_FORMAT
|
||||||
)
|
)
|
||||||
|
user_name = tables.Column(
|
||||||
|
verbose_name='Username'
|
||||||
|
)
|
||||||
|
full_name = tables.TemplateColumn(
|
||||||
|
template_code=OBJECTCHANGE_FULL_NAME,
|
||||||
|
verbose_name='Full Name',
|
||||||
|
orderable=False
|
||||||
|
)
|
||||||
action = ChoiceFieldColumn()
|
action = ChoiceFieldColumn()
|
||||||
changed_object_type = ContentTypeColumn(
|
changed_object_type = ContentTypeColumn(
|
||||||
verbose_name='Type'
|
verbose_name='Type'
|
||||||
@ -219,7 +232,7 @@ class ObjectChangeTable(BaseTable):
|
|||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
model = ObjectChange
|
model = ObjectChange
|
||||||
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
|
||||||
|
|
||||||
|
|
||||||
class ObjectJournalTable(BaseTable):
|
class ObjectJournalTable(BaseTable):
|
||||||
|
@ -12,7 +12,7 @@ from extras.filtersets import *
|
|||||||
from extras.models import *
|
from extras.models import *
|
||||||
from ipam.models import IPAddress
|
from ipam.models import IPAddress
|
||||||
from tenancy.models import Tenant, TenantGroup
|
from tenancy.models import Tenant, TenantGroup
|
||||||
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests
|
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
|
||||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||||
|
|
||||||
|
|
||||||
@ -429,6 +429,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
)
|
)
|
||||||
Tenant.objects.bulk_create(tenants)
|
Tenant.objects.bulk_create(tenants)
|
||||||
|
|
||||||
|
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||||
|
|
||||||
for i in range(0, 3):
|
for i in range(0, 3):
|
||||||
is_active = bool(i % 2)
|
is_active = bool(i % 2)
|
||||||
c = ConfigContext.objects.create(
|
c = ConfigContext.objects.create(
|
||||||
@ -446,6 +448,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
c.clusters.set([clusters[i]])
|
c.clusters.set([clusters[i]])
|
||||||
c.tenant_groups.set([tenant_groups[i]])
|
c.tenant_groups.set([tenant_groups[i]])
|
||||||
c.tenants.set([tenants[i]])
|
c.tenants.set([tenants[i]])
|
||||||
|
c.tags.set([tags[i]])
|
||||||
|
|
||||||
def test_name(self):
|
def test_name(self):
|
||||||
params = {'name': ['Config Context 1', 'Config Context 2']}
|
params = {'name': ['Config Context 1', 'Config Context 2']}
|
||||||
@ -516,13 +519,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_tenant_(self):
|
def test_tenant(self):
|
||||||
tenants = Tenant.objects.all()[:2]
|
tenants = Tenant.objects.all()[:2]
|
||||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_tags(self):
|
||||||
|
tags = Tag.objects.all()[:2]
|
||||||
|
params = {'tag_id': [tags[0].pk, tags[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'tag': [tags[0].slug, tags[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = Tag.objects.all()
|
queryset = Tag.objects.all()
|
||||||
|
@ -448,7 +448,8 @@ class ObjectChangeLogView(View):
|
|||||||
)
|
)
|
||||||
objectchanges_table = tables.ObjectChangeTable(
|
objectchanges_table = tables.ObjectChangeTable(
|
||||||
data=objectchanges,
|
data=objectchanges,
|
||||||
orderable=False
|
orderable=False,
|
||||||
|
user=request.user
|
||||||
)
|
)
|
||||||
paginate_table(objectchanges_table, request)
|
paginate_table(objectchanges_table, request)
|
||||||
|
|
||||||
|
@ -20,19 +20,28 @@ PARAMS = (
|
|||||||
name='BANNER_LOGIN',
|
name='BANNER_LOGIN',
|
||||||
label='Login banner',
|
label='Login banner',
|
||||||
default='',
|
default='',
|
||||||
description="Additional content to display on the login page"
|
description="Additional content to display on the login page",
|
||||||
|
field_kwargs={
|
||||||
|
'widget': forms.Textarea(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ConfigParam(
|
ConfigParam(
|
||||||
name='BANNER_TOP',
|
name='BANNER_TOP',
|
||||||
label='Top banner',
|
label='Top banner',
|
||||||
default='',
|
default='',
|
||||||
description="Additional content to display at the top of every page"
|
description="Additional content to display at the top of every page",
|
||||||
|
field_kwargs={
|
||||||
|
'widget': forms.Textarea(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
ConfigParam(
|
ConfigParam(
|
||||||
name='BANNER_BOTTOM',
|
name='BANNER_BOTTOM',
|
||||||
label='Bottom banner',
|
label='Bottom banner',
|
||||||
default='',
|
default='',
|
||||||
description="Additional content to display at the bottom of every page"
|
description="Additional content to display at the bottom of every page",
|
||||||
|
field_kwargs={
|
||||||
|
'widget': forms.Textarea(),
|
||||||
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
# IPAM
|
# IPAM
|
||||||
|
@ -18,7 +18,7 @@ from ipam.filtersets import (
|
|||||||
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
|
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
|
||||||
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
|
||||||
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
|
from tenancy.filtersets import ContactFilterSet, TenantFilterSet
|
||||||
from tenancy.models import Contact, Tenant
|
from tenancy.models import Contact, Tenant, ContactAssignment
|
||||||
from tenancy.tables import ContactTable, TenantTable
|
from tenancy.tables import ContactTable, TenantTable
|
||||||
from utilities.utils import count_related
|
from utilities.utils import count_related
|
||||||
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
|
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
|
||||||
@ -186,7 +186,7 @@ SEARCH_TYPES = OrderedDict((
|
|||||||
'url': 'tenancy:tenant_list',
|
'url': 'tenancy:tenant_list',
|
||||||
}),
|
}),
|
||||||
('contact', {
|
('contact', {
|
||||||
'queryset': Contact.objects.prefetch_related('group', 'assignments'),
|
'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
|
||||||
'filterset': ContactFilterSet,
|
'filterset': ContactFilterSet,
|
||||||
'table': ContactTable,
|
'table': ContactTable,
|
||||||
'url': 'tenancy:contact_list',
|
'url': 'tenancy:contact_list',
|
||||||
|
@ -19,7 +19,7 @@ from netbox.config import PARAMS
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '3.1.7'
|
VERSION = '3.1.8'
|
||||||
|
|
||||||
# Hostname
|
# Hostname
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
|
@ -133,7 +133,7 @@ class HomeView(View):
|
|||||||
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
|
||||||
'user', 'changed_object_type'
|
'user', 'changed_object_type'
|
||||||
)[:10]
|
)[:10]
|
||||||
changelog_table = ObjectChangeTable(changelog)
|
changelog_table = ObjectChangeTable(changelog, user=request.user)
|
||||||
|
|
||||||
# Check whether a new release is available. (Only for staff/superusers.)
|
# Check whether a new release is available. (Only for staff/superusers.)
|
||||||
new_release = None
|
new_release = None
|
||||||
|
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
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.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
|
|||||||
import { isStaticParams, isOption } from './types';
|
import { isStaticParams, isOption } from './types';
|
||||||
import {
|
import {
|
||||||
hasMore,
|
hasMore,
|
||||||
isTruthy,
|
|
||||||
hasError,
|
hasError,
|
||||||
getElement,
|
isTruthy,
|
||||||
getApiData,
|
getApiData,
|
||||||
|
getElement,
|
||||||
isApiError,
|
isApiError,
|
||||||
|
replaceAll,
|
||||||
createElement,
|
createElement,
|
||||||
uniqueByProperty,
|
uniqueByProperty,
|
||||||
findFirstAdjacent,
|
findFirstAdjacent,
|
||||||
@ -461,7 +462,7 @@ export class APISelect {
|
|||||||
// Set any primitive k/v pairs as data attributes on each option.
|
// Set any primitive k/v pairs as data attributes on each option.
|
||||||
for (const [k, v] of Object.entries(result)) {
|
for (const [k, v] of Object.entries(result)) {
|
||||||
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
|
||||||
const key = k.replaceAll('_', '-');
|
const key = replaceAll(k, '_', '-');
|
||||||
data[key] = String(v);
|
data[key] = String(v);
|
||||||
}
|
}
|
||||||
// Set option to disabled if the result contains a matching key and is truthy.
|
// Set option to disabled if the result contains a matching key and is truthy.
|
||||||
@ -659,7 +660,7 @@ export class APISelect {
|
|||||||
for (const [key, value] of this.pathValues.entries()) {
|
for (const [key, value] of this.pathValues.entries()) {
|
||||||
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
|
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
|
||||||
if (isTruthy(value)) {
|
if (isTruthy(value)) {
|
||||||
url = url.replaceAll(result[1], value.toString());
|
url = replaceAll(url, result[1], value.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -741,7 +742,7 @@ export class APISelect {
|
|||||||
* @param id DOM ID of the other element.
|
* @param id DOM ID of the other element.
|
||||||
*/
|
*/
|
||||||
private updatePathValues(id: string): void {
|
private updatePathValues(id: string): void {
|
||||||
const key = id.replaceAll(/^id_/gi, '');
|
const key = replaceAll(id, /^id_/i, '');
|
||||||
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
const element = getElement<HTMLSelectElement>(`id_${key}`);
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
// If this element's URL contains Django template tags ({{), replace the template tag
|
// If this element's URL contains Django template tags ({{), replace the template tag
|
||||||
@ -919,16 +920,18 @@ export class APISelect {
|
|||||||
style.setAttribute('data-netbox', id);
|
style.setAttribute('data-netbox', id);
|
||||||
|
|
||||||
// Scope the CSS to apply both the list item and the selected item.
|
// Scope the CSS to apply both the list item and the selected item.
|
||||||
style.innerHTML = `
|
style.innerHTML = replaceAll(
|
||||||
|
`
|
||||||
div.ss-values div.ss-value[data-id="${id}"],
|
div.ss-values div.ss-value[data-id="${id}"],
|
||||||
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
|
||||||
{
|
{
|
||||||
background-color: ${bg} !important;
|
background-color: ${bg} !important;
|
||||||
color: ${fg} !important;
|
color: ${fg} !important;
|
||||||
}
|
}
|
||||||
`
|
`,
|
||||||
.replaceAll('\n', '')
|
'\n',
|
||||||
.trim();
|
'',
|
||||||
|
).trim();
|
||||||
|
|
||||||
// Add the style element to the DOM.
|
// Add the style element to the DOM.
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
@ -11,15 +11,6 @@ function saveTableConfig(): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete all selected columns, which reverts the user's preferences to the default column set.
|
|
||||||
*/
|
|
||||||
function resetTableConfig(): void {
|
|
||||||
for (const element of getElements<HTMLSelectElement>('select[name="columns"]')) {
|
|
||||||
element.value = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add columns to the table config select element.
|
* Add columns to the table config select element.
|
||||||
*/
|
*/
|
||||||
@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
|
|||||||
/**
|
/**
|
||||||
* Submit form configuration to the NetBox API.
|
* Submit form configuration to the NetBox API.
|
||||||
*/
|
*/
|
||||||
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
|
async function submitFormConfig(
|
||||||
|
url: string,
|
||||||
|
formConfig: Dict<Dict>,
|
||||||
|
): Promise<APIResponse<APIUserConfig>> {
|
||||||
return await apiPatch<APIUserConfig>(url, formConfig);
|
return await apiPatch<APIUserConfig>(url, formConfig);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,25 +64,46 @@ function handleSubmit(event: Event): void {
|
|||||||
const url = element.getAttribute('data-url');
|
const url = element.getAttribute('data-url');
|
||||||
if (url == null) {
|
if (url == null) {
|
||||||
const toast = createToast(
|
const toast = createToast(
|
||||||
'danger',
|
'danger',
|
||||||
'Error Updating Table Configuration',
|
'Error Updating Table Configuration',
|
||||||
'No API path defined for configuration form.'
|
'No API path defined for configuration form.',
|
||||||
);
|
);
|
||||||
toast.show();
|
toast.show();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine if the form action is to reset the table config.
|
||||||
|
const reset = document.activeElement?.getAttribute('value') === 'Reset';
|
||||||
|
|
||||||
|
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
|
||||||
|
// ['tables', 'DevicePowerOutletTable']
|
||||||
|
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
|
||||||
|
|
||||||
|
if (reset) {
|
||||||
|
// If we're resetting the table config, create an empty object for this table. E.g.
|
||||||
|
// tables.PlatformTable becomes {tables: PlatformTable: {}}
|
||||||
|
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), {});
|
||||||
|
|
||||||
|
// Submit the reset for configuration to the API.
|
||||||
|
submitFormConfig(url, data).then(res => {
|
||||||
|
if (hasError(res)) {
|
||||||
|
const toast = createToast('danger', 'Error Resetting Table Configuration', res.error);
|
||||||
|
toast.show();
|
||||||
|
} else {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Get all the selected options from any select element in the form.
|
// Get all the selected options from any select element in the form.
|
||||||
const options = getSelectedOptions(element);
|
const options = getSelectedOptions(element, 'select[name=columns]');
|
||||||
|
|
||||||
// Create an object mapping the select element's name to all selected options for that element.
|
// Create an object mapping the select element's name to all selected options for that element.
|
||||||
const formData: Dict<Dict<string>> = Object.assign(
|
const formData: Dict<Dict<string>> = Object.assign(
|
||||||
{},
|
{},
|
||||||
...options.map(opt => ({ [opt.name]: opt.options })),
|
...options.map(opt => ({ [opt.name]: opt.options })),
|
||||||
);
|
);
|
||||||
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
|
|
||||||
// ['tables', 'DevicePowerOutletTable']
|
|
||||||
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
|
|
||||||
|
|
||||||
// Create an object mapping the configuration path to the select element names, which contain the
|
// Create an object mapping the configuration path to the select element names, which contain the
|
||||||
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
|
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
|
||||||
@ -112,9 +127,6 @@ export function initTableConfig(): void {
|
|||||||
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
|
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
|
||||||
element.addEventListener('click', saveTableConfig);
|
element.addEventListener('click', saveTableConfig);
|
||||||
}
|
}
|
||||||
for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
|
|
||||||
element.addEventListener('click', resetTableConfig);
|
|
||||||
}
|
|
||||||
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
|
for (const element of getElements<HTMLButtonElement>('#add_columns')) {
|
||||||
element.addEventListener('click', addColumns);
|
element.addEventListener('click', addColumns);
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { getElements, findFirstAdjacent } from '../util';
|
import { getElements, replaceAll, findFirstAdjacent } from '../util';
|
||||||
|
|
||||||
type InterfaceState = 'enabled' | 'disabled';
|
type InterfaceState = 'enabled' | 'disabled';
|
||||||
type ShowHide = 'show' | 'hide';
|
type ShowHide = 'show' | 'hide';
|
||||||
@ -105,9 +105,9 @@ class ButtonState {
|
|||||||
*/
|
*/
|
||||||
private toggleButton(): void {
|
private toggleButton(): void {
|
||||||
if (this.buttonState === 'show') {
|
if (this.buttonState === 'show') {
|
||||||
this.button.innerText = this.button.innerText.replaceAll('Show', 'Hide');
|
this.button.innerText = replaceAll(this.button.innerText, 'Show', 'Hide');
|
||||||
} else if (this.buttonState === 'hide') {
|
} else if (this.buttonState === 'hide') {
|
||||||
this.button.innerText = this.button.innerText.replaceAll('Hide', 'Show');
|
this.button.innerText = replaceAll(this.button.innerHTML, 'Hide', 'Show');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -231,11 +231,15 @@ export function scrollTo(element: Element, offset: number = 0): void {
|
|||||||
* Iterate through a select element's options and return an array of options that are selected.
|
* Iterate through a select element's options and return an array of options that are selected.
|
||||||
*
|
*
|
||||||
* @param base Select element.
|
* @param base Select element.
|
||||||
|
* @param selector Optionally specify a selector. 'select' by default.
|
||||||
* @returns Array of selected options.
|
* @returns Array of selected options.
|
||||||
*/
|
*/
|
||||||
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] {
|
export function getSelectedOptions<E extends HTMLElement>(
|
||||||
|
base: E,
|
||||||
|
selector: string = 'select',
|
||||||
|
): SelectedOption[] {
|
||||||
let selected = [] as SelectedOption[];
|
let selected = [] as SelectedOption[];
|
||||||
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) {
|
for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
const select = { name: element.name, options: [] } as SelectedOption;
|
const select = { name: element.name, options: [] } as SelectedOption;
|
||||||
for (const option of element.options) {
|
for (const option of element.options) {
|
||||||
@ -315,7 +319,7 @@ export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
|
|||||||
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
|
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
|
||||||
if (element !== null) {
|
if (element !== null) {
|
||||||
if (isTruthy(element.innerText) && element.innerText !== '—') {
|
if (isTruthy(element.innerText) && element.innerText !== '—') {
|
||||||
yield element.innerText.replaceAll(/[\n\r]/g, '').trim();
|
yield replaceAll(element.innerText, '[\n\r]', '').trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -436,3 +440,49 @@ export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[],
|
|||||||
}
|
}
|
||||||
return Array.from(baseMap.values());
|
return Array.from(baseMap.values());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Replace all occurrences of a pattern with a replacement string.
|
||||||
|
*
|
||||||
|
* This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`,
|
||||||
|
* introduced in ES2021.
|
||||||
|
*
|
||||||
|
* @param input string to be processed.
|
||||||
|
* @param pattern regex pattern string or RegExp object to search for.
|
||||||
|
* @param replacement replacement substring with which `pattern` matches will be replaced.
|
||||||
|
* @returns processed version of `input`.
|
||||||
|
*/
|
||||||
|
export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string {
|
||||||
|
// Ensure input is a string.
|
||||||
|
if (typeof input !== 'string') {
|
||||||
|
throw new TypeError("replaceAll 'input' argument must be a string");
|
||||||
|
}
|
||||||
|
// Ensure pattern is a string or RegExp.
|
||||||
|
if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) {
|
||||||
|
throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance");
|
||||||
|
}
|
||||||
|
// Ensure replacement is able to be stringified.
|
||||||
|
switch (typeof replacement) {
|
||||||
|
case 'boolean':
|
||||||
|
replacement = String(replacement);
|
||||||
|
break;
|
||||||
|
case 'number':
|
||||||
|
replacement = String(replacement);
|
||||||
|
break;
|
||||||
|
case 'string':
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw new TypeError("replaceAll 'replacement' argument must be stringifyable");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pattern instanceof RegExp) {
|
||||||
|
// Add global flag to existing RegExp object and deduplicate
|
||||||
|
const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join('');
|
||||||
|
pattern = new RegExp(pattern.source, flags);
|
||||||
|
} else {
|
||||||
|
// Create a RegExp object with the global flag set.
|
||||||
|
pattern = new RegExp(pattern, 'g');
|
||||||
|
}
|
||||||
|
|
||||||
|
return input.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
@ -33,7 +33,8 @@
|
|||||||
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
|
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<th scope="row">Location</th>
|
<tr>
|
||||||
|
<th scope="row">Location</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.location %}
|
{% if object.location %}
|
||||||
{% for location in object.location.get_ancestors %}
|
{% for location in object.location.get_ancestors %}
|
||||||
@ -129,7 +130,7 @@
|
|||||||
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% badge vc_member.vc_position %}
|
{% badge vc_member.vc_position show_empty=True %}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}
|
||||||
|
@ -38,7 +38,11 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="row">User</th>
|
<th scope="row">User</th>
|
||||||
<td>
|
<td>
|
||||||
{{ object.user|default:object.user_name }}
|
{% if object.user.get_full_name %}
|
||||||
|
{{ object.user.get_full_name }} ({{ object.user_name }})
|
||||||
|
{% else %}
|
||||||
|
{{ object.user_name }}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -4,10 +4,13 @@ from django.contrib.auth.models import AnonymousUser
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import FieldDoesNotExist
|
from django.core.exceptions import FieldDoesNotExist
|
||||||
|
from django.db.models import DateField, DateTimeField
|
||||||
from django.db.models.fields.related import RelatedField
|
from django.db.models.fields.related import RelatedField
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.formats import date_format
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django_tables2 import RequestConfig
|
from django_tables2 import RequestConfig
|
||||||
|
from django_tables2.columns import library
|
||||||
from django_tables2.data import TableQuerysetData
|
from django_tables2.data import TableQuerysetData
|
||||||
from django_tables2.utils import Accessor
|
from django_tables2.utils import Accessor
|
||||||
|
|
||||||
@ -205,6 +208,42 @@ class TemplateColumn(tables.TemplateColumn):
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
@library.register
|
||||||
|
class DateColumn(tables.DateColumn):
|
||||||
|
"""
|
||||||
|
Overrides the default implementation of DateColumn to better handle null values, returning a default value for
|
||||||
|
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||||
|
default, making this behavior consistent in all fields of type DateField.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def value(self, value):
|
||||||
|
return value
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_field(cls, field, **kwargs):
|
||||||
|
if isinstance(field, DateField):
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
@library.register
|
||||||
|
class DateTimeColumn(tables.DateTimeColumn):
|
||||||
|
"""
|
||||||
|
Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for
|
||||||
|
tables and null when exporting data. It is registered in the tables library to use this class instead of the
|
||||||
|
default, making this behavior consistent in all fields of type DateTimeField.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def value(self, value):
|
||||||
|
if value:
|
||||||
|
return date_format(value, format="SHORT_DATETIME_FORMAT")
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_field(cls, field, **kwargs):
|
||||||
|
if isinstance(field, DateTimeField):
|
||||||
|
return cls(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
class ButtonsColumn(tables.TemplateColumn):
|
class ButtonsColumn(tables.TemplateColumn):
|
||||||
"""
|
"""
|
||||||
Render edit, delete, and changelog buttons for an object.
|
Render edit, delete, and changelog buttons for an object.
|
||||||
|
@ -183,7 +183,7 @@ def deepmerge(original, new):
|
|||||||
"""
|
"""
|
||||||
merged = OrderedDict(original)
|
merged = OrderedDict(original)
|
||||||
for key, val in new.items():
|
for key, val in new.items():
|
||||||
if key in original and isinstance(original[key], dict) and isinstance(val, dict):
|
if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
|
||||||
merged[key] = deepmerge(original[key], val)
|
merged[key] = deepmerge(original[key], val)
|
||||||
else:
|
else:
|
||||||
merged[key] = val
|
merged[key] = val
|
||||||
|
@ -64,7 +64,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
|
|||||||
class VirtualMachineCSVForm(CustomFieldModelCSVForm):
|
class VirtualMachineCSVForm(CustomFieldModelCSVForm):
|
||||||
status = CSVChoiceField(
|
status = CSVChoiceField(
|
||||||
choices=VirtualMachineStatusChoices,
|
choices=VirtualMachineStatusChoices,
|
||||||
help_text='Operational status of device'
|
help_text='Operational status'
|
||||||
)
|
)
|
||||||
cluster = CSVModelChoiceField(
|
cluster = CSVModelChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
|
@ -9,7 +9,7 @@ django-prometheus==2.2.0
|
|||||||
django-redis==5.2.0
|
django-redis==5.2.0
|
||||||
django-rq==2.5.1
|
django-rq==2.5.1
|
||||||
django-tables2==2.4.1
|
django-tables2==2.4.1
|
||||||
django-taggit==2.0.0
|
django-taggit==2.1.0
|
||||||
django-timezone-field==4.2.3
|
django-timezone-field==4.2.3
|
||||||
djangorestframework==3.12.4
|
djangorestframework==3.12.4
|
||||||
drf-yasg[validation]==1.20.0
|
drf-yasg[validation]==1.20.0
|
||||||
@ -18,9 +18,9 @@ gunicorn==20.1.0
|
|||||||
Jinja2==3.0.3
|
Jinja2==3.0.3
|
||||||
Markdown==3.3.6
|
Markdown==3.3.6
|
||||||
markdown-include==0.6.0
|
markdown-include==0.6.0
|
||||||
mkdocs-material==8.1.9
|
mkdocs-material==8.1.11
|
||||||
netaddr==0.8.0
|
netaddr==0.8.0
|
||||||
Pillow==8.4.0
|
Pillow==9.0.1
|
||||||
psycopg2-binary==2.9.3
|
psycopg2-binary==2.9.3
|
||||||
PyYAML==6.0
|
PyYAML==6.0
|
||||||
social-auth-app-django==5.0.0
|
social-auth-app-django==5.0.0
|
||||||
|
Loading…
Reference in New Issue
Block a user