mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
commit
b1d1b51304
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -26,7 +26,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: v4.0.5
|
placeholder: v4.0.6
|
||||||
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: v4.0.5
|
placeholder: v4.0.6
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@ -5,10 +5,12 @@ on:
|
|||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'contrib/**'
|
- 'contrib/**'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
- 'netbox/translations/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- 'contrib/**'
|
- 'contrib/**'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
|
- 'netbox/translations/**'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -28,3 +28,4 @@ netbox.pid
|
|||||||
.idea
|
.idea
|
||||||
.coverage
|
.coverage
|
||||||
.vscode
|
.vscode
|
||||||
|
.python-version
|
||||||
|
@ -17,7 +17,6 @@ NetBox exists to empower network engineers. Since its release in 2016, it has be
|
|||||||
<a href="#why-netbox">Why NetBox?</a> |
|
<a href="#why-netbox">Why NetBox?</a> |
|
||||||
<a href="#getting-started">Getting Started</a> |
|
<a href="#getting-started">Getting Started</a> |
|
||||||
<a href="#get-involved">Get Involved</a> |
|
<a href="#get-involved">Get Involved</a> |
|
||||||
<a href="#project-stats">Project Stats</a> |
|
|
||||||
<a href="#screenshots">Screenshots</a>
|
<a href="#screenshots">Screenshots</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
@ -24,7 +24,7 @@ If you believe you've uncovered a security vulnerability and wish to report it c
|
|||||||
|
|
||||||
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
Please note that we **DO NOT** accept reports generated by automated tooling which merely suggest that a file or file(s) _may_ be vulnerable under certain conditions, as these are most often innocuous.
|
||||||
|
|
||||||
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub, or email a brief description of the suspected bug and instructions for reproduction to **security@netbox.dev**. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
If you believe that you've found a vulnerability which meets all of these conditions, please [submit a draft security advisory](https://github.com/netbox-community/netbox/security/advisories/new) on GitHub. For any security concerns regarding NetBox deployed via Docker, please see the [netbox-docker](https://github.com/netbox-community/netbox-docker) project.
|
||||||
|
|
||||||
### Bug Bounties
|
### Bug Bounties
|
||||||
|
|
||||||
|
@ -8,7 +8,9 @@ django-cors-headers
|
|||||||
|
|
||||||
# Runtime UI tool for debugging Django
|
# Runtime UI tool for debugging Django
|
||||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||||
django-debug-toolbar
|
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
|
||||||
|
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
|
||||||
|
django-debug-toolbar==4.3.0
|
||||||
|
|
||||||
# Library for writing reusable URL query filters
|
# Library for writing reusable URL query filters
|
||||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||||
@ -108,7 +110,7 @@ Pillow
|
|||||||
|
|
||||||
# PostgreSQL database adapter for Python
|
# PostgreSQL database adapter for Python
|
||||||
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
|
# https://github.com/psycopg/psycopg/blob/master/docs/news.rst
|
||||||
psycopg[binary,pool]
|
psycopg[c,pool]
|
||||||
|
|
||||||
# YAML rendering library
|
# YAML rendering library
|
||||||
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
# https://github.com/yaml/pyyaml/blob/master/CHANGES
|
||||||
|
95605
contrib/openapi2.json
95605
contrib/openapi2.json
File diff suppressed because it is too large
Load Diff
69695
contrib/openapi2.yaml
69695
contrib/openapi2.yaml
File diff suppressed because it is too large
Load Diff
@ -138,11 +138,11 @@ These two methods will load data in YAML or JSON format, respectively, from file
|
|||||||
|
|
||||||
The Script object provides a set of convenient functions for recording messages at different severity levels:
|
The Script object provides a set of convenient functions for recording messages at different severity levels:
|
||||||
|
|
||||||
* `log_debug(message, object=None)`
|
* `log_debug(message=None, obj=None)`
|
||||||
* `log_success(message, object=None)`
|
* `log_success(message=None, obj=None)`
|
||||||
* `log_info(message, object=None)`
|
* `log_info(message=None, obj=None)`
|
||||||
* `log_warning(message, object=None)`
|
* `log_warning(message=None, obj=None)`
|
||||||
* `log_failure(message, object=None)`
|
* `log_failure(message=None, obj=None)`
|
||||||
|
|
||||||
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
|
Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
|
||||||
|
|
||||||
@ -152,6 +152,8 @@ A script can define one or more test methods to report on certain conditions. Al
|
|||||||
|
|
||||||
These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.)
|
These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.)
|
||||||
|
|
||||||
|
Calling any of these logging methods without a message will increment the relevant counter, but will not generate an output line in the script's log.
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.
|
This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.
|
||||||
|
|
||||||
|
@ -126,3 +126,13 @@ VERSION = 'v3.3.2-dev'
|
|||||||
```
|
```
|
||||||
|
|
||||||
Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream.
|
Commit this change with the comment "PRVB" (for _post-release version bump_) and push the commit upstream.
|
||||||
|
|
||||||
|
### Update the Public Documentation
|
||||||
|
|
||||||
|
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
|
||||||
|
|
||||||
|
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
|
||||||
|
|
||||||
|
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
|
||||||
|
|
||||||
|
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.
|
||||||
|
@ -1,5 +1,35 @@
|
|||||||
# NetBox v4.0
|
# NetBox v4.0
|
||||||
|
|
||||||
|
## v4.0.6 (2024-06-24)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#15348](https://github.com/netbox-community/netbox/issues/15348) - Show saved filters alongside quick search on object list views
|
||||||
|
* [#15794](https://github.com/netbox-community/netbox/issues/15794) - Dynamically populate related objects in UI views
|
||||||
|
* [#16256](https://github.com/netbox-community/netbox/issues/16256) - Enable alphabetical ordering of bookmarks on dashboard
|
||||||
|
* [#16307](https://github.com/netbox-community/netbox/issues/16307) - Enable calling `log_*()` methods on Script without passing a message
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13925](https://github.com/netbox-community/netbox/issues/13925) - Fix support for "zulu" (UTC) timestamps for custom fields
|
||||||
|
* [#14829](https://github.com/netbox-community/netbox/issues/14829) - Fix support for simple conditions (without AND/OR) in event rules
|
||||||
|
* [#15717](https://github.com/netbox-community/netbox/issues/15717) - Allow assigning a device/VM in a site to a cluster with no site assigned
|
||||||
|
* [#16143](https://github.com/netbox-community/netbox/issues/16143) - Display timestamps in tables in the configured timezone
|
||||||
|
* [#16149](https://github.com/netbox-community/netbox/issues/16149) - Fix object linking in custom script logs
|
||||||
|
* [#16252](https://github.com/netbox-community/netbox/issues/16252) - Fix total count in tab at top of rack elevations view
|
||||||
|
* [#16273](https://github.com/netbox-community/netbox/issues/16273) - Restore global search bar on mobile
|
||||||
|
* [#16416](https://github.com/netbox-community/netbox/issues/16416) - Retain dark/light mode toggle on mobile view
|
||||||
|
* [#16444](https://github.com/netbox-community/netbox/issues/16444) - Disable ordering circuits list by A/Z termination
|
||||||
|
* [#16450](https://github.com/netbox-community/netbox/issues/16450) - Searching for rack unit in form dropdown should be case-insensitive
|
||||||
|
* [#16452](https://github.com/netbox-community/netbox/issues/16452) - Fix sizing of buttons within object attribute panels
|
||||||
|
* [#16454](https://github.com/netbox-community/netbox/issues/16454) - Address DNS lookup bug in `django-debug-toolbar
|
||||||
|
* [#16460](https://github.com/netbox-community/netbox/issues/16460) - Omit spaces from telephone number URLs
|
||||||
|
* [#16512](https://github.com/netbox-community/netbox/issues/16512) - Restore a user's preferred language (if any) on login
|
||||||
|
* [#16542](https://github.com/netbox-community/netbox/issues/16542) - Fix bulk form operations when HTMX is enabled
|
||||||
|
* [#16702](https://github.com/netbox-community/netbox/issues/16702) - Fix validation of `return_url` query parameter
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## v4.0.5 (2024-06-06)
|
## v4.0.5 (2024-06-06)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
@ -104,10 +104,16 @@ class LoginView(View):
|
|||||||
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
# Ensure the user has a UserConfig defined. (This should normally be handled by
|
||||||
# create_userconfig() on user creation.)
|
# create_userconfig() on user creation.)
|
||||||
if not hasattr(request.user, 'config'):
|
if not hasattr(request.user, 'config'):
|
||||||
config = get_config()
|
request.user.config = get_config()
|
||||||
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
|
UserConfig(user=request.user, data=request.user.config.DEFAULT_USER_PREFERENCES).save()
|
||||||
|
|
||||||
return self.redirect_to_next(request, logger)
|
response = self.redirect_to_next(request, logger)
|
||||||
|
|
||||||
|
# Set the user's preferred language (if any)
|
||||||
|
if language := request.user.config.get('locale.language'):
|
||||||
|
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
else:
|
else:
|
||||||
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
|
||||||
@ -145,9 +151,10 @@ class LogoutView(View):
|
|||||||
logger.info(f"User {username} has logged out")
|
logger.info(f"User {username} has logged out")
|
||||||
messages.info(request, "You have logged out.")
|
messages.info(request, "You have logged out.")
|
||||||
|
|
||||||
# Delete session key cookie (if set) upon logout
|
# Delete session key & language cookies (if set) upon logout
|
||||||
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
|
||||||
response.delete_cookie('session_key')
|
response.delete_cookie('session_key')
|
||||||
|
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
|
||||||
|
|
||||||
return response
|
return response
|
||||||
|
|
||||||
|
@ -63,10 +63,12 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
status = columns.ChoiceFieldColumn()
|
status = columns.ChoiceFieldColumn()
|
||||||
termination_a = tables.TemplateColumn(
|
termination_a = tables.TemplateColumn(
|
||||||
template_code=CIRCUITTERMINATION_LINK,
|
template_code=CIRCUITTERMINATION_LINK,
|
||||||
|
orderable=False,
|
||||||
verbose_name=_('Side A')
|
verbose_name=_('Side A')
|
||||||
)
|
)
|
||||||
termination_z = tables.TemplateColumn(
|
termination_z = tables.TemplateColumn(
|
||||||
template_code=CIRCUITTERMINATION_LINK,
|
template_code=CIRCUITTERMINATION_LINK,
|
||||||
|
orderable=False,
|
||||||
verbose_name=_('Side Z')
|
verbose_name=_('Side Z')
|
||||||
)
|
)
|
||||||
commit_rate = CommitRateColumn(
|
commit_rate = CommitRateColumn(
|
||||||
|
@ -7,7 +7,7 @@ from netbox.views import generic
|
|||||||
from tenancy.views import ObjectContactsView
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.views import register_model_view
|
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -26,17 +26,12 @@ class ProviderListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Provider)
|
@register_model_view(Provider)
|
||||||
class ProviderView(generic.ObjectView):
|
class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Provider.objects.all()
|
queryset = Provider.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(ProviderAccount.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
|
|
||||||
(Circuit.objects.restrict(request.user, 'view').filter(provider=instance), 'provider_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -92,16 +87,12 @@ class ProviderAccountListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ProviderAccount)
|
@register_model_view(ProviderAccount)
|
||||||
class ProviderAccountView(generic.ObjectView):
|
class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ProviderAccount.objects.all()
|
queryset = ProviderAccount.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Circuit.objects.restrict(request.user, 'view').filter(provider_account=instance), 'provider_account_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -156,19 +147,21 @@ class ProviderNetworkListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ProviderNetwork)
|
@register_model_view(ProviderNetwork)
|
||||||
class ProviderNetworkView(generic.ObjectView):
|
class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ProviderNetwork.objects.all()
|
queryset = ProviderNetwork.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(
|
|
||||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
|
||||||
'provider_network_id',
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
extra=(
|
||||||
|
(
|
||||||
|
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||||
|
'provider_network_id',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -215,16 +208,12 @@ class CircuitTypeListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(CircuitType)
|
@register_model_view(CircuitType)
|
||||||
class CircuitTypeView(generic.ObjectView):
|
class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = CircuitType.objects.all()
|
queryset = CircuitType.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Circuit.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -32,7 +32,7 @@ from netbox.views.generic.mixins import TableMixin
|
|||||||
from utilities.forms import ConfirmationForm
|
from utilities.forms import ConfirmationForm
|
||||||
from utilities.htmx import htmx_partial
|
from utilities.htmx import htmx_partial
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
|
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -51,16 +51,12 @@ class DataSourceListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(DataSource)
|
@register_model_view(DataSource)
|
||||||
class DataSourceView(generic.ObjectView):
|
class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = DataSource.objects.all()
|
queryset = DataSource.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(DataFile.objects.restrict(request.user, 'view').filter(source=instance), 'source_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -219,9 +219,9 @@ class RackViewSet(NetBoxModelViewSet):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Enable filtering rack units by ID
|
# Enable filtering rack units by ID
|
||||||
q = data['q']
|
if q := data['q']:
|
||||||
if q:
|
q = q.lower()
|
||||||
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name'])]
|
elevation = [u for u in elevation if q in str(u['id']) or q in str(u['name']).lower()]
|
||||||
|
|
||||||
page = self.paginate_queryset(elevation)
|
page = self.paginate_queryset(elevation)
|
||||||
if page is not None:
|
if page is not None:
|
||||||
|
@ -465,7 +465,10 @@ class DeviceForm(TenancyForm, NetBoxModelForm):
|
|||||||
label=_('Cluster'),
|
label=_('Cluster'),
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
selector=True
|
selector=True,
|
||||||
|
query_params={
|
||||||
|
'site_id': ['$site', 'null']
|
||||||
|
},
|
||||||
)
|
)
|
||||||
comments = CommentField()
|
comments = CommentField()
|
||||||
local_context_data = JSONField(
|
local_context_data = JSONField(
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django_tables2.utils import Accessor
|
from django_tables2.utils import Accessor
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from dcim.models import Cable
|
from dcim.models import Cable
|
||||||
@ -35,7 +36,7 @@ class CableTerminationsColumn(tables.Column):
|
|||||||
|
|
||||||
def render(self, value):
|
def render(self, value):
|
||||||
links = [
|
links = [
|
||||||
f'<a href="{term.get_absolute_url()}">{term}</a>' for term in self._get_terminations(value)
|
f'<a href="{term.get_absolute_url()}">{escape(term)}</a>' for term in self._get_terminations(value)
|
||||||
]
|
]
|
||||||
return mark_safe('<br />'.join(links) or '—')
|
return mark_safe('<br />'.join(links) or '—')
|
||||||
|
|
||||||
|
@ -8,6 +8,7 @@ from dcim.models import *
|
|||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.data import drange
|
from utilities.data import drange
|
||||||
|
from virtualization.models import Cluster, ClusterType
|
||||||
|
|
||||||
|
|
||||||
class LocationTestCase(TestCase):
|
class LocationTestCase(TestCase):
|
||||||
@ -533,6 +534,36 @@ class DeviceTestCase(TestCase):
|
|||||||
device2.full_clean()
|
device2.full_clean()
|
||||||
device2.save()
|
device2.save()
|
||||||
|
|
||||||
|
def test_device_mismatched_site_cluster(self):
|
||||||
|
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||||
|
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||||
|
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
clusters = (
|
||||||
|
Cluster(name='Cluster 1', type=cluster_type, site=sites[0]),
|
||||||
|
Cluster(name='Cluster 2', type=cluster_type, site=sites[1]),
|
||||||
|
Cluster(name='Cluster 3', type=cluster_type, site=None),
|
||||||
|
)
|
||||||
|
Cluster.objects.bulk_create(clusters)
|
||||||
|
|
||||||
|
device_type = DeviceType.objects.first()
|
||||||
|
device_role = DeviceRole.objects.first()
|
||||||
|
|
||||||
|
# Device with site only should pass
|
||||||
|
Device(name='device1', site=sites[0], device_type=device_type, role=device_role).full_clean()
|
||||||
|
|
||||||
|
# Device with site, cluster non-site should pass
|
||||||
|
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[2]).full_clean()
|
||||||
|
|
||||||
|
# Device with mismatched site & cluster should fail
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean()
|
||||||
|
|
||||||
|
|
||||||
class CableTestCase(TestCase):
|
class CableTestCase(TestCase):
|
||||||
|
|
||||||
|
@ -17,7 +17,7 @@ from jinja2.exceptions import TemplateError
|
|||||||
|
|
||||||
from circuits.models import Circuit, CircuitTermination
|
from circuits.models import Circuit, CircuitTermination
|
||||||
from extras.views import ObjectConfigContextView
|
from extras.views import ObjectConfigContextView
|
||||||
from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup
|
from ipam.models import ASN, IPAddress, VLANGroup
|
||||||
from ipam.tables import InterfaceVLANTable
|
from ipam.tables import InterfaceVLANTable
|
||||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
@ -27,7 +27,9 @@ from utilities.paginator import EnhancedPaginator, get_paginate_count
|
|||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.query_functions import CollateAsChar
|
from utilities.query_functions import CollateAsChar
|
||||||
from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
from utilities.views import (
|
||||||
|
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
|
||||||
|
)
|
||||||
from virtualization.filtersets import VirtualMachineFilterSet
|
from virtualization.filtersets import VirtualMachineFilterSet
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
from virtualization.tables import VirtualMachineTable
|
from virtualization.tables import VirtualMachineTable
|
||||||
@ -226,19 +228,21 @@ class RegionListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Region)
|
@register_model_view(Region)
|
||||||
class RegionView(generic.ObjectView):
|
class RegionView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Region.objects.all()
|
queryset = Region.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
regions = instance.get_descendants(include_self=True)
|
regions = instance.get_descendants(include_self=True)
|
||||||
related_models = (
|
|
||||||
(Site.objects.restrict(request.user, 'view').filter(region__in=regions), 'region_id'),
|
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
regions,
|
||||||
|
extra=(
|
||||||
|
(Location.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||||
|
(Rack.objects.restrict(request.user, 'view').filter(site__region__in=regions), 'region_id'),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -306,19 +310,21 @@ class SiteGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(SiteGroup)
|
@register_model_view(SiteGroup)
|
||||||
class SiteGroupView(generic.ObjectView):
|
class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = SiteGroup.objects.all()
|
queryset = SiteGroup.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
groups = instance.get_descendants(include_self=True)
|
groups = instance.get_descendants(include_self=True)
|
||||||
related_models = (
|
|
||||||
(Site.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
groups,
|
||||||
|
extra=(
|
||||||
|
(Location.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
|
(Rack.objects.restrict(request.user, 'view').filter(site__group__in=groups), 'site_group_id'),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -380,31 +386,25 @@ class SiteListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Site)
|
@register_model_view(Site)
|
||||||
class SiteView(generic.ObjectView):
|
class SiteView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Site.objects.prefetch_related('tenant__group')
|
queryset = Site.objects.prefetch_related('tenant__group')
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
# DCIM
|
|
||||||
(Location.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
|
||||||
(Device.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
|
||||||
# Virtualization
|
|
||||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=instance), 'site_id'),
|
|
||||||
# IPAM
|
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
|
||||||
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
|
|
||||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
|
||||||
scope_type=ContentType.objects.get_for_model(Site),
|
|
||||||
scope_id=instance.pk
|
|
||||||
), 'site'),
|
|
||||||
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
|
||||||
# Circuits
|
|
||||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
[CableTermination, CircuitTermination],
|
||||||
|
(
|
||||||
|
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||||
|
scope_type=ContentType.objects.get_for_model(Site),
|
||||||
|
scope_id=instance.pk
|
||||||
|
), 'site'),
|
||||||
|
(ASN.objects.restrict(request.user, 'view').filter(sites=instance), 'site_id'),
|
||||||
|
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(),
|
||||||
|
'site_id'),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -466,18 +466,13 @@ class LocationListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Location)
|
@register_model_view(Location)
|
||||||
class LocationView(generic.ObjectView):
|
class LocationView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Location.objects.all()
|
queryset = Location.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
locations = instance.get_descendants(include_self=True)
|
locations = instance.get_descendants(include_self=True)
|
||||||
related_models = (
|
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
|
||||||
(Device.objects.restrict(request.user, 'view').filter(location__in=locations), 'location_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, locations, [CableTermination]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -541,16 +536,12 @@ class RackRoleListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(RackRole)
|
@register_model_view(RackRole)
|
||||||
class RackRoleView(generic.ObjectView):
|
class RackRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = RackRole.objects.all()
|
queryset = RackRole.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Rack.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -655,15 +646,10 @@ class RackElevationListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Rack)
|
@register_model_view(Rack)
|
||||||
class RackView(generic.ObjectView):
|
class RackView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
|
queryset = Rack.objects.prefetch_related('site__region', 'tenant__group', 'location', 'role')
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Device.objects.restrict(request.user, 'view').filter(rack=instance), 'rack_id'),
|
|
||||||
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
|
||||||
|
|
||||||
if instance.location:
|
if instance.location:
|
||||||
@ -679,7 +665,7 @@ class RackView(generic.ObjectView):
|
|||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance, [CableTermination]),
|
||||||
'next_rack': next_rack,
|
'next_rack': next_rack,
|
||||||
'prev_rack': prev_rack,
|
'prev_rack': prev_rack,
|
||||||
'svg_extra': svg_extra,
|
'svg_extra': svg_extra,
|
||||||
@ -838,19 +824,12 @@ class ManufacturerListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Manufacturer)
|
@register_model_view(Manufacturer)
|
||||||
class ManufacturerView(generic.ObjectView):
|
class ManufacturerView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Manufacturer.objects.all()
|
queryset = Manufacturer.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(DeviceType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
|
||||||
(ModuleType.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
|
||||||
(InventoryItem.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
|
||||||
(Platform.objects.restrict(request.user, 'view').filter(manufacturer=instance), 'manufacturer_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance, [InventoryItemTemplate]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -912,16 +891,16 @@ class DeviceTypeListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(DeviceType)
|
@register_model_view(DeviceType)
|
||||||
class DeviceTypeView(generic.ObjectView):
|
class DeviceTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = DeviceType.objects.all()
|
queryset = DeviceType.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Device.objects.restrict(request.user).filter(device_type=instance), 'device_type_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance, omit=[
|
||||||
|
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
|
||||||
|
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
|
||||||
|
RearPortTemplate,
|
||||||
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1151,16 +1130,16 @@ class ModuleTypeListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ModuleType)
|
@register_model_view(ModuleType)
|
||||||
class ModuleTypeView(generic.ObjectView):
|
class ModuleTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ModuleType.objects.all()
|
queryset = ModuleType.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Module.objects.restrict(request.user).filter(module_type=instance), 'module_type_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance, omit=[
|
||||||
|
ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, FrontPortTemplate,
|
||||||
|
InventoryItemTemplate, InterfaceTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate,
|
||||||
|
RearPortTemplate,
|
||||||
|
]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1711,17 +1690,12 @@ class DeviceRoleListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(DeviceRole)
|
@register_model_view(DeviceRole)
|
||||||
class DeviceRoleView(generic.ObjectView):
|
class DeviceRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = DeviceRole.objects.all()
|
queryset = DeviceRole.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Device.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -1775,17 +1749,12 @@ class PlatformListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Platform)
|
@register_model_view(Platform)
|
||||||
class PlatformView(generic.ObjectView):
|
class PlatformView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Platform.objects.all()
|
queryset = Platform.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Device.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
|
|
||||||
(VirtualMachine.objects.restrict(request.user, 'view').filter(platform=instance), 'platform_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -2157,22 +2126,12 @@ class ModuleListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Module)
|
@register_model_view(Module)
|
||||||
class ModuleView(generic.ObjectView):
|
class ModuleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Module.objects.all()
|
queryset = Module.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Interface.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
(ConsolePort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
(ConsoleServerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
(PowerPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
(PowerOutlet.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
(FrontPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
(RearPort.objects.restrict(request.user, 'view').filter(module=instance), 'module_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -3451,8 +3410,9 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
|
|||||||
if membership_form.is_valid():
|
if membership_form.is_valid():
|
||||||
|
|
||||||
membership_form.save()
|
membership_form.save()
|
||||||
msg = f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
|
messages.success(request, mark_safe(
|
||||||
messages.success(request, mark_safe(msg))
|
f'Added member <a href="{device.get_absolute_url()}">{escape(device)}</a>'
|
||||||
|
))
|
||||||
|
|
||||||
if '_addanother' in request.POST:
|
if '_addanother' in request.POST:
|
||||||
return redirect(request.get_full_path())
|
return redirect(request.get_full_path())
|
||||||
@ -3552,16 +3512,12 @@ class PowerPanelListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(PowerPanel)
|
@register_model_view(PowerPanel)
|
||||||
class PowerPanelView(generic.ObjectView):
|
class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = PowerPanel.objects.all()
|
queryset = PowerPanel.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(PowerFeed.objects.restrict(request.user).filter(power_panel=instance), 'power_panel_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -3665,16 +3621,18 @@ class VirtualDeviceContextListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(VirtualDeviceContext)
|
@register_model_view(VirtualDeviceContext)
|
||||||
class VirtualDeviceContextView(generic.ObjectView):
|
class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = VirtualDeviceContext.objects.all()
|
queryset = VirtualDeviceContext.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
extra=(
|
||||||
|
(Interface.objects.restrict(request.user, 'view').filter(vdcs__in=[instance]), 'vdc_id'),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -117,10 +117,14 @@ class BookmarkOrderingChoices(ChoiceSet):
|
|||||||
|
|
||||||
ORDERING_NEWEST = '-created'
|
ORDERING_NEWEST = '-created'
|
||||||
ORDERING_OLDEST = 'created'
|
ORDERING_OLDEST = 'created'
|
||||||
|
ORDERING_ALPHABETICAL_AZ = 'name'
|
||||||
|
ORDERING_ALPHABETICAL_ZA = '-name'
|
||||||
|
|
||||||
CHOICES = (
|
CHOICES = (
|
||||||
(ORDERING_NEWEST, _('Newest')),
|
(ORDERING_NEWEST, _('Newest')),
|
||||||
(ORDERING_OLDEST, _('Oldest')),
|
(ORDERING_OLDEST, _('Oldest')),
|
||||||
|
(ORDERING_ALPHABETICAL_AZ, _('Alphabetical (A-Z)')),
|
||||||
|
(ORDERING_ALPHABETICAL_ZA, _('Alphabetical (Z-A)')),
|
||||||
)
|
)
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -135,23 +135,23 @@ class ConditionSet:
|
|||||||
def __init__(self, ruleset):
|
def __init__(self, ruleset):
|
||||||
if type(ruleset) is not dict:
|
if type(ruleset) is not dict:
|
||||||
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
||||||
if len(ruleset) != 1:
|
|
||||||
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
|
|
||||||
ruleset=len(ruleset)))
|
|
||||||
|
|
||||||
# Determine the logic type
|
if len(ruleset) == 1:
|
||||||
logic = list(ruleset.keys())[0]
|
self.logic = (list(ruleset.keys())[0]).lower()
|
||||||
if type(logic) is not str or logic.lower() not in (AND, OR):
|
if self.logic not in (AND, OR):
|
||||||
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
|
raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
|
||||||
logic=logic, op_and=AND, op_or=OR
|
|
||||||
))
|
|
||||||
self.logic = logic.lower()
|
|
||||||
|
|
||||||
# Compile the set of Conditions
|
# Compile the set of Conditions
|
||||||
self.conditions = [
|
self.conditions = [
|
||||||
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
|
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
|
||||||
for rule in ruleset[self.logic]
|
for rule in ruleset[self.logic]
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.logic = None
|
||||||
|
self.conditions = [Condition(**ruleset)]
|
||||||
|
except TypeError:
|
||||||
|
raise ValueError(_("Incorrect key(s) informed. Please check documentation."))
|
||||||
|
|
||||||
def eval(self, data):
|
def eval(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -381,11 +381,17 @@ class BookmarksWidget(DashboardWidget):
|
|||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
bookmarks = list()
|
bookmarks = list()
|
||||||
else:
|
else:
|
||||||
bookmarks = Bookmark.objects.filter(user=request.user).order_by(self.config['order_by'])
|
user_bookmarks = Bookmark.objects.filter(user=request.user)
|
||||||
|
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
|
||||||
|
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower())
|
||||||
|
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
|
||||||
|
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
|
||||||
|
else:
|
||||||
|
bookmarks = user_bookmarks.order_by(self.config['order_by'])
|
||||||
if object_types := self.config.get('object_types'):
|
if object_types := self.config.get('object_types'):
|
||||||
models = get_models_from_content_types(object_types)
|
models = get_models_from_content_types(object_types)
|
||||||
conent_types = ObjectType.objects.get_for_models(*models).values()
|
content_types = ObjectType.objects.get_for_models(*models).values()
|
||||||
bookmarks = bookmarks.filter(object_type__in=conent_types)
|
bookmarks = bookmarks.filter(object_type__in=content_types)
|
||||||
if max_items := self.config.get('max_items'):
|
if max_items := self.config.get('max_items'):
|
||||||
bookmarks = bookmarks[:max_items]
|
bookmarks = bookmarks[:max_items]
|
||||||
|
|
||||||
|
@ -10,6 +10,7 @@ from django.contrib.postgres.fields import ArrayField
|
|||||||
from django.core.validators import RegexValidator, ValidationError
|
from django.core.validators import RegexValidator, ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
@ -520,7 +521,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
RegexValidator(
|
RegexValidator(
|
||||||
regex=self.validation_regex,
|
regex=self.validation_regex,
|
||||||
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
|
message=mark_safe(_("Values must match this regex: <code>{regex}</code>").format(
|
||||||
regex=self.validation_regex
|
regex=escape(self.validation_regex)
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
@ -660,6 +661,10 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
|||||||
# Validate date & time
|
# Validate date & time
|
||||||
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
|
||||||
if type(value) is not datetime:
|
if type(value) is not datetime:
|
||||||
|
# Work around UTC issue for Python < 3.11; see
|
||||||
|
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
|
||||||
|
if type(value) is str and value.endswith('Z'):
|
||||||
|
value = f'{value[:-1]}+00:00'
|
||||||
try:
|
try:
|
||||||
datetime.fromisoformat(value)
|
datetime.fromisoformat(value)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
@ -480,19 +480,21 @@ class BaseScript:
|
|||||||
# A test method is currently active, so log the message using legacy Report logging
|
# A test method is currently active, so log the message using legacy Report logging
|
||||||
if self._current_test:
|
if self._current_test:
|
||||||
|
|
||||||
# TODO: Use a dataclass for test method logs
|
|
||||||
self.tests[self._current_test]['log'].append((
|
|
||||||
timezone.now().isoformat(),
|
|
||||||
level,
|
|
||||||
str(obj) if obj else None,
|
|
||||||
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
|
||||||
str(message),
|
|
||||||
))
|
|
||||||
|
|
||||||
# Increment the event counter for this level
|
# Increment the event counter for this level
|
||||||
if level in self.tests[self._current_test]:
|
if level in self.tests[self._current_test]:
|
||||||
self.tests[self._current_test][level] += 1
|
self.tests[self._current_test][level] += 1
|
||||||
|
|
||||||
|
# Record message (if any) to the report log
|
||||||
|
if message:
|
||||||
|
# TODO: Use a dataclass for test method logs
|
||||||
|
self.tests[self._current_test]['log'].append((
|
||||||
|
timezone.now().isoformat(),
|
||||||
|
level,
|
||||||
|
str(obj) if obj else None,
|
||||||
|
obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||||
|
str(message),
|
||||||
|
))
|
||||||
|
|
||||||
elif message:
|
elif message:
|
||||||
|
|
||||||
# Record to the script's log
|
# Record to the script's log
|
||||||
@ -500,6 +502,8 @@ class BaseScript:
|
|||||||
'time': timezone.now().isoformat(),
|
'time': timezone.now().isoformat(),
|
||||||
'status': level,
|
'status': level,
|
||||||
'message': str(message),
|
'message': str(message),
|
||||||
|
'obj': str(obj) if obj else None,
|
||||||
|
'url': obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Record to the system log
|
# Record to the system log
|
||||||
@ -507,19 +511,19 @@ class BaseScript:
|
|||||||
message = f"{obj}: {message}"
|
message = f"{obj}: {message}"
|
||||||
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
|
self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
|
||||||
|
|
||||||
def log_debug(self, message, obj=None):
|
def log_debug(self, message=None, obj=None):
|
||||||
self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
|
self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
|
||||||
|
|
||||||
def log_success(self, message, obj=None):
|
def log_success(self, message=None, obj=None):
|
||||||
self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
|
self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
|
||||||
|
|
||||||
def log_info(self, message, obj=None):
|
def log_info(self, message=None, obj=None):
|
||||||
self._log(message, obj, level=LogLevelChoices.LOG_INFO)
|
self._log(message, obj, level=LogLevelChoices.LOG_INFO)
|
||||||
|
|
||||||
def log_warning(self, message, obj=None):
|
def log_warning(self, message=None, obj=None):
|
||||||
self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
|
self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
|
||||||
|
|
||||||
def log_failure(self, message, obj=None):
|
def log_failure(self, message=None, obj=None):
|
||||||
self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
|
self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
|
||||||
self.failed = True
|
self.failed = True
|
||||||
|
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
|
from django.utils.html import format_html
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from extras.models import *
|
from extras.models import *
|
||||||
@ -545,6 +546,9 @@ class ScriptResultsTable(BaseTable):
|
|||||||
template_code="""{% load log_levels %}{% log_level record.status %}""",
|
template_code="""{% load log_levels %}{% log_level record.status %}""",
|
||||||
verbose_name=_('Level')
|
verbose_name=_('Level')
|
||||||
)
|
)
|
||||||
|
object = tables.Column(
|
||||||
|
verbose_name=_('Object')
|
||||||
|
)
|
||||||
message = columns.MarkdownColumn(
|
message = columns.MarkdownColumn(
|
||||||
verbose_name=_('Message')
|
verbose_name=_('Message')
|
||||||
)
|
)
|
||||||
@ -552,8 +556,17 @@ class ScriptResultsTable(BaseTable):
|
|||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
empty_text = _(EMPTY_TABLE_TEXT)
|
empty_text = _(EMPTY_TABLE_TEXT)
|
||||||
fields = (
|
fields = (
|
||||||
'index', 'time', 'status', 'message',
|
'index', 'time', 'status', 'object', 'message',
|
||||||
)
|
)
|
||||||
|
default_columns = (
|
||||||
|
'index', 'time', 'status', 'object', 'message',
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_object(self, value, record):
|
||||||
|
return format_html("<a href='{}'>{}</a>", record['url'], value)
|
||||||
|
|
||||||
|
def render_url(self, value):
|
||||||
|
return format_html("<a href='{}'>{}</a>", value, value)
|
||||||
|
|
||||||
|
|
||||||
class ReportResultsTable(BaseTable):
|
class ReportResultsTable(BaseTable):
|
||||||
@ -585,3 +598,9 @@ class ReportResultsTable(BaseTable):
|
|||||||
fields = (
|
fields = (
|
||||||
'index', 'method', 'time', 'status', 'object', 'url', 'message',
|
'index', 'method', 'time', 'status', 'object', 'url', 'message',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def render_object(self, value, record):
|
||||||
|
return format_html("<a href='{}'>{}</a>", record['url'], value)
|
||||||
|
|
||||||
|
def render_url(self, value):
|
||||||
|
return format_html("<a href='{}'>{}</a>", value, value)
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
@ -59,8 +60,7 @@ def custom_links(context, obj):
|
|||||||
# Add non-grouped links
|
# Add non-grouped links
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
rendered = cl.render(link_context)
|
if rendered := cl.render(link_context):
|
||||||
if rendered:
|
|
||||||
template_code += LINK_BUTTON.format(
|
template_code += LINK_BUTTON.format(
|
||||||
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
|
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
|
||||||
)
|
)
|
||||||
@ -75,8 +75,7 @@ def custom_links(context, obj):
|
|||||||
|
|
||||||
for cl in links:
|
for cl in links:
|
||||||
try:
|
try:
|
||||||
rendered = cl.render(link_context)
|
if rendered := cl.render(link_context):
|
||||||
if rendered:
|
|
||||||
links_rendered.append(
|
links_rendered.append(
|
||||||
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
|
GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
|
||||||
)
|
)
|
||||||
@ -88,7 +87,7 @@ def custom_links(context, obj):
|
|||||||
|
|
||||||
if links_rendered:
|
if links_rendered:
|
||||||
template_code += GROUP_BUTTON.format(
|
template_code += GROUP_BUTTON.format(
|
||||||
links[0].button_class, group, ''.join(links_rendered)
|
links[0].button_class, escape(group), ''.join(links_rendered)
|
||||||
)
|
)
|
||||||
|
|
||||||
return mark_safe(template_code)
|
return mark_safe(template_code)
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.choices import SiteStatusChoices
|
||||||
|
from dcim.models import Site
|
||||||
from extras.conditions import Condition, ConditionSet
|
from extras.conditions import Condition, ConditionSet
|
||||||
|
from extras.events import serialize_for_event
|
||||||
|
from extras.forms import EventRuleForm
|
||||||
|
from extras.models import EventRule, Webhook
|
||||||
|
|
||||||
|
|
||||||
class ConditionTestCase(TestCase):
|
class ConditionTestCase(TestCase):
|
||||||
@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
|
|||||||
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
|
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
|
||||||
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
|
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
|
||||||
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
|
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_without_logic_operator(self):
|
||||||
|
"""
|
||||||
|
Test evaluation of EventRule conditions without logic operator.
|
||||||
|
"""
|
||||||
|
event_rule = EventRule(
|
||||||
|
name='Event Rule 1',
|
||||||
|
type_create=True,
|
||||||
|
type_update=True,
|
||||||
|
conditions={
|
||||||
|
'attr': 'status.value',
|
||||||
|
'value': 'active',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a Site to evaluate - Status = active
|
||||||
|
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||||
|
data = serialize_for_event(site)
|
||||||
|
|
||||||
|
# Evaluate the conditions (status='active')
|
||||||
|
self.assertTrue(event_rule.eval_conditions(data))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_with_logical_operation(self):
|
||||||
|
"""
|
||||||
|
Test evaluation of EventRule conditions without logic operator, but with logical operation (in).
|
||||||
|
"""
|
||||||
|
event_rule = EventRule(
|
||||||
|
name='Event Rule 1',
|
||||||
|
type_create=True,
|
||||||
|
type_update=True,
|
||||||
|
conditions={
|
||||||
|
"attr": "status.value",
|
||||||
|
"value": ["planned", "staging"],
|
||||||
|
"op": "in",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a Site to evaluate - Status = active
|
||||||
|
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||||
|
data = serialize_for_event(site)
|
||||||
|
|
||||||
|
# Evaluate the conditions (status in ['planned, 'staging'])
|
||||||
|
self.assertFalse(event_rule.eval_conditions(data))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_with_logical_operation_and_negate(self):
|
||||||
|
"""
|
||||||
|
Test evaluation of EventRule with logical operation (in) and negate.
|
||||||
|
"""
|
||||||
|
event_rule = EventRule(
|
||||||
|
name='Event Rule 1',
|
||||||
|
type_create=True,
|
||||||
|
type_update=True,
|
||||||
|
conditions={
|
||||||
|
"attr": "status.value",
|
||||||
|
"value": ["planned", "staging"],
|
||||||
|
"op": "in",
|
||||||
|
"negate": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a Site to evaluate - Status = active
|
||||||
|
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||||
|
data = serialize_for_event(site)
|
||||||
|
|
||||||
|
# Evaluate the conditions (status NOT in ['planned, 'staging'])
|
||||||
|
self.assertTrue(event_rule.eval_conditions(data))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_with_incorrect_key_must_return_false(self):
|
||||||
|
"""
|
||||||
|
Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ct = ContentType.objects.get(app_label='extras', model='webhook')
|
||||||
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
|
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
|
||||||
|
form = EventRuleForm({
|
||||||
|
"name": "Event Rule 1",
|
||||||
|
"type_create": True,
|
||||||
|
"type_update": True,
|
||||||
|
"action_object_type": ct.pk,
|
||||||
|
"action_type": "webhook",
|
||||||
|
"action_choice": webhook.pk,
|
||||||
|
"content_types": [site_ct.pk],
|
||||||
|
"conditions": {
|
||||||
|
"foo": "status.value",
|
||||||
|
"value": "active"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
@ -1201,6 +1201,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
|||||||
'time': log.get('time'),
|
'time': log.get('time'),
|
||||||
'status': log.get('status'),
|
'status': log.get('status'),
|
||||||
'message': log.get('message'),
|
'message': log.get('message'),
|
||||||
|
'object': log.get('obj'),
|
||||||
|
'url': log.get('url'),
|
||||||
}
|
}
|
||||||
data.append(result)
|
data.append(result)
|
||||||
|
|
||||||
|
@ -12,7 +12,7 @@ from netbox.views import generic
|
|||||||
from tenancy.views import ObjectContactsView
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.tables import get_table_ordering
|
from utilities.tables import get_table_ordering
|
||||||
from utilities.views import ViewTab, register_model_view
|
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||||
from virtualization.filtersets import VMInterfaceFilterSet
|
from virtualization.filtersets import VMInterfaceFilterSet
|
||||||
from virtualization.models import VMInterface
|
from virtualization.models import VMInterface
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
@ -34,15 +34,10 @@ class VRFListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(VRF)
|
@register_model_view(VRF)
|
||||||
class VRFView(generic.ObjectView):
|
class VRFView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = VRF.objects.all()
|
queryset = VRF.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
|
|
||||||
(IPAddress.objects.restrict(request.user, 'view').filter(vrf=instance), 'vrf_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
import_targets_table = tables.RouteTargetTable(
|
import_targets_table = tables.RouteTargetTable(
|
||||||
instance.import_targets.all(),
|
instance.import_targets.all(),
|
||||||
orderable=False
|
orderable=False
|
||||||
@ -53,7 +48,7 @@ class VRFView(generic.ObjectView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance, omit=[Interface, VMInterface]),
|
||||||
'import_targets_table': import_targets_table,
|
'import_targets_table': import_targets_table,
|
||||||
'export_targets_table': export_targets_table,
|
'export_targets_table': export_targets_table,
|
||||||
}
|
}
|
||||||
@ -147,16 +142,12 @@ class RIRListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(RIR)
|
@register_model_view(RIR)
|
||||||
class RIRView(generic.ObjectView):
|
class RIRView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = RIR.objects.all()
|
queryset = RIR.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Aggregate.objects.restrict(request.user, 'view').filter(rir=instance), 'rir_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -273,17 +264,19 @@ class ASNListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ASN)
|
@register_model_view(ASN)
|
||||||
class ASNView(generic.ObjectView):
|
class ASNView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ASN.objects.all()
|
queryset = ASN.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
|
||||||
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(
|
||||||
|
request,
|
||||||
|
instance,
|
||||||
|
extra=(
|
||||||
|
(Site.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||||
|
(Provider.objects.restrict(request.user, 'view').filter(asns__in=[instance]), 'asn_id'),
|
||||||
|
),
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -427,18 +420,12 @@ class RoleListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Role)
|
@register_model_view(Role)
|
||||||
class RoleView(generic.ObjectView):
|
class RoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Role.objects.all()
|
queryset = Role.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Prefix.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
(IPRange.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
(VLAN.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -926,16 +913,12 @@ class VLANGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(VLANGroup)
|
@register_model_view(VLANGroup)
|
||||||
class VLANGroupView(generic.ObjectView):
|
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(VLAN.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -25,7 +25,7 @@ from utilities.string import trailing_slash
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '4.0.5'
|
VERSION = '4.0.6'
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
# Set the base directory two levels up
|
# Set the base directory two levels up
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@ -551,7 +551,7 @@ if SENTRY_ENABLED:
|
|||||||
|
|
||||||
# Calculate a unique deployment ID from the secret key
|
# Calculate a unique deployment ID from the secret key
|
||||||
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
|
DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16]
|
||||||
CENSUS_URL = 'https://census.netbox.dev/api/v1/'
|
CENSUS_URL = 'https://census.netbox.oss.netboxlabs.com/api/v1/'
|
||||||
CENSUS_PARAMS = {
|
CENSUS_PARAMS = {
|
||||||
'version': VERSION,
|
'version': VERSION,
|
||||||
'python_version': sys.version.split()[0],
|
'python_version': sys.version.split()[0],
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import zoneinfo
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
@ -83,6 +84,8 @@ class DateTimeColumn(tables.Column):
|
|||||||
|
|
||||||
def render(self, value):
|
def render(self, value):
|
||||||
if value:
|
if value:
|
||||||
|
current_tz = zoneinfo.ZoneInfo(settings.TIME_ZONE)
|
||||||
|
value = value.astimezone(current_tz)
|
||||||
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
|
return f"{value.date().isoformat()} {value.time().isoformat(timespec=self.timespec)}"
|
||||||
|
|
||||||
def value(self, value):
|
def value(self, value):
|
||||||
@ -430,7 +433,7 @@ class LinkedCountColumn(tables.Column):
|
|||||||
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
|
f'{k}={getattr(record, v) or settings.FILTERS_NULL_CHOICE_VALUE}'
|
||||||
for k, v in self.url_params.items()
|
for k, v in self.url_params.items()
|
||||||
])
|
])
|
||||||
return mark_safe(f'<a href="{url}">{value}</a>')
|
return mark_safe(f'<a href="{url}">{escape(value)}</a>')
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def value(self, value):
|
def value(self, value):
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
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.
@ -27,10 +27,10 @@
|
|||||||
"bootstrap": "5.3.3",
|
"bootstrap": "5.3.3",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"gridstack": "10.2.0",
|
"gridstack": "10.2.1",
|
||||||
"htmx.org": "1.9.12",
|
"htmx.org": "1.9.12",
|
||||||
"query-string": "9.0.0",
|
"query-string": "9.0.0",
|
||||||
"sass": "1.77.4",
|
"sass": "1.77.6",
|
||||||
"tom-select": "2.3.1",
|
"tom-select": "2.3.1",
|
||||||
"typeface-inter": "3.18.1",
|
"typeface-inter": "3.18.1",
|
||||||
"typeface-roboto-mono": "1.1.13"
|
"typeface-roboto-mono": "1.1.13"
|
||||||
|
30
netbox/project-static/src/forms/savedFiltersSelect.ts
Normal file
30
netbox/project-static/src/forms/savedFiltersSelect.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import { isTruthy } from '../util';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle saved filter change event.
|
||||||
|
*
|
||||||
|
* @param event "change" event for the saved filter select
|
||||||
|
*/
|
||||||
|
function handleSavedFilterChange(event: Event): void {
|
||||||
|
const savedFilter = event.currentTarget as HTMLSelectElement;
|
||||||
|
let baseUrl = savedFilter.baseURI.split('?')[0];
|
||||||
|
const preFilter = '?';
|
||||||
|
|
||||||
|
const selectedOptions = Array.from(savedFilter.options)
|
||||||
|
.filter(option => option.selected)
|
||||||
|
.map(option => `filter_id=${option.value}`)
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
baseUrl += `${preFilter}${selectedOptions}`;
|
||||||
|
document.location.href = baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function initSavedFilterSelect(): void {
|
||||||
|
const divResults = document.getElementById('results');
|
||||||
|
if (isTruthy(divResults)) {
|
||||||
|
const savedFilterSelect = document.getElementById('id_filter_id');
|
||||||
|
if (isTruthy(savedFilterSelect)) {
|
||||||
|
savedFilterSelect.addEventListener('change', handleSavedFilterChange);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -13,6 +13,7 @@ import { initSideNav } from './sidenav';
|
|||||||
import { initDashboard } from './dashboard';
|
import { initDashboard } from './dashboard';
|
||||||
import { initRackElevation } from './racks';
|
import { initRackElevation } from './racks';
|
||||||
import { initHtmx } from './htmx';
|
import { initHtmx } from './htmx';
|
||||||
|
import { initSavedFilterSelect } from './forms/savedFiltersSelect';
|
||||||
|
|
||||||
function initDocument(): void {
|
function initDocument(): void {
|
||||||
for (const init of [
|
for (const init of [
|
||||||
@ -31,6 +32,7 @@ function initDocument(): void {
|
|||||||
initDashboard,
|
initDashboard,
|
||||||
initRackElevation,
|
initRackElevation,
|
||||||
initHtmx,
|
initHtmx,
|
||||||
|
initSavedFilterSelect,
|
||||||
]) {
|
]) {
|
||||||
init();
|
init();
|
||||||
}
|
}
|
||||||
|
@ -7,6 +7,7 @@
|
|||||||
// Overrides of external libraries
|
// Overrides of external libraries
|
||||||
@import 'overrides/bootstrap';
|
@import 'overrides/bootstrap';
|
||||||
@import 'overrides/tabler';
|
@import 'overrides/tabler';
|
||||||
|
@import 'overrides/tomselect';
|
||||||
|
|
||||||
// Transitional styling to ease migration of templates from NetBox v3.x
|
// Transitional styling to ease migration of templates from NetBox v3.x
|
||||||
@import 'transitional/badges';
|
@import 'transitional/badges';
|
||||||
|
8
netbox/project-static/styles/overrides/_tomselect.scss
Normal file
8
netbox/project-static/styles/overrides/_tomselect.scss
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
.ts-wrapper.multi {
|
||||||
|
.ts-control {
|
||||||
|
padding: 7px 7px 3px 7px;
|
||||||
|
div {
|
||||||
|
margin: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1754,10 +1754,10 @@ graphql@16.8.1:
|
|||||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
||||||
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
||||||
|
|
||||||
gridstack@10.2.0:
|
gridstack@10.2.1:
|
||||||
version "10.2.0"
|
version "10.2.1"
|
||||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.0.tgz#4ba9c7ee69a730851721a9f5cb33dc55026ded1f"
|
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca"
|
||||||
integrity sha512-svKAOq/dfinpvhe/nnxdyZOOEd9qynXiOPHvL96PALE0yWChWp/6lechnqKwud0tL/rRyAfMJ6Hh/z2fS13pBA==
|
integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw==
|
||||||
|
|
||||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
is-regex "^1.1.4"
|
is-regex "^1.1.4"
|
||||||
|
|
||||||
sass@1.77.4:
|
sass@1.77.6:
|
||||||
version "1.77.4"
|
version "1.77.6"
|
||||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.4.tgz#92059c7bfc56b827c56eb116778d157ec017a5cd"
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4"
|
||||||
integrity sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==
|
integrity sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar ">=3.0.0 <4.0.0"
|
chokidar ">=3.0.0 <4.0.0"
|
||||||
immutable "^4.0.0"
|
immutable "^4.0.0"
|
||||||
|
@ -35,6 +35,7 @@ Blocks:
|
|||||||
|
|
||||||
{# User menu (mobile view) #}
|
{# User menu (mobile view) #}
|
||||||
<div class="navbar-nav flex-row d-lg-none">
|
<div class="navbar-nav flex-row d-lg-none">
|
||||||
|
{% include 'inc/light_toggle.html' %}
|
||||||
{% include 'inc/user_menu.html' %}
|
{% include 'inc/user_menu.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -52,14 +53,7 @@ Blocks:
|
|||||||
|
|
||||||
<div class="navbar-nav flex-row align-items-center order-md-last">
|
<div class="navbar-nav flex-row align-items-center order-md-last">
|
||||||
{# Dark/light mode toggle #}
|
{# Dark/light mode toggle #}
|
||||||
<div class="d-none d-md-flex">
|
{% include 'inc/light_toggle.html' %}
|
||||||
<button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
|
||||||
<i class="mdi mdi-lightbulb"></i>
|
|
||||||
</button>
|
|
||||||
<button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
|
||||||
<i class="mdi mdi-lightbulb-on"></i>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# User menu #}
|
{# User menu #}
|
||||||
{% include 'inc/user_menu.html' %}
|
{% include 'inc/user_menu.html' %}
|
||||||
|
@ -28,7 +28,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Rack" %}</th>
|
<th scope="row">{% trans "Rack" %}</th>
|
||||||
<td class="d-flex justify-content-between">
|
<td class="d-flex justify-content-between align-items-start">
|
||||||
{% if object.rack %}
|
{% if object.rack %}
|
||||||
{{ object.rack|linkify }}
|
{{ object.rack|linkify }}
|
||||||
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
|
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm d-print-none" title="{% trans "Highlight device in rack" %}">
|
||||||
|
@ -73,7 +73,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Physical Address" %}</th>
|
<th scope="row">{% trans "Physical Address" %}</th>
|
||||||
<td class="d-flex justify-content-between">
|
<td class="d-flex justify-content-between align-items-start">
|
||||||
{% if object.physical_address %}
|
{% if object.physical_address %}
|
||||||
<span>{{ object.physical_address|linebreaksbr }}</span>
|
<span>{{ object.physical_address|linebreaksbr }}</span>
|
||||||
{% if config.MAPS_URL %}
|
{% if config.MAPS_URL %}
|
||||||
|
@ -44,7 +44,7 @@
|
|||||||
{# Object table controls #}
|
{# Object table controls #}
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-auto ms-auto d-print-none">
|
<div class="col-auto ms-auto d-print-none">
|
||||||
{% if request.user.is_authenticated %}
|
{% if request.user.is_authenticated and job.completed %}
|
||||||
<div class="table-configure input-group">
|
<div class="table-configure input-group">
|
||||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config"
|
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#ObjectTable_config"
|
||||||
class="btn">
|
class="btn">
|
||||||
|
@ -48,7 +48,7 @@ Context:
|
|||||||
<li class="nav-item" role="presentation">
|
<li class="nav-item" role="presentation">
|
||||||
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
|
<a class="nav-link active" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
|
||||||
{% trans "Results" %}
|
{% trans "Results" %}
|
||||||
<span class="badge text-bg-secondary total-object-count">{{ table.page.paginator.count }}</span>
|
<span class="badge text-bg-secondary total-object-count">{% if table.page.paginator.count %}{{ table.page.paginator.count }}{% else %}{{ total_count }}{% endif %}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% if filter_form %}
|
{% if filter_form %}
|
||||||
|
10
netbox/templates/inc/light_toggle.html
Normal file
10
netbox/templates/inc/light_toggle.html
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
<div class="d-flex">
|
||||||
|
<button class="btn color-mode-toggle hide-theme-dark" title="{% trans "Enable dark mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||||
|
<i class="mdi mdi-lightbulb"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn color-mode-toggle hide-theme-light" title="{% trans "Enable light mode" %}" data-bs-toggle="tooltip" data-bs-placement="bottom">
|
||||||
|
<i class="mdi mdi-lightbulb-on"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
@ -1,25 +1,37 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3" id="results">
|
||||||
<div class="col-auto d-print-none">
|
<div class="col-auto d-print-none">
|
||||||
<div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
|
<div class="input-group input-group-flat me-2 quicksearch" hx-disinherit="hx-select hx-swap">
|
||||||
<input type="search" results="5" name="q" id="quicksearch" class="form-control px-2 py-1" placeholder="Quick search"
|
<input type="search" results="5" name="q" id="quicksearch" class="form-control" placeholder="{% trans "Quick search" %}"
|
||||||
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search" />
|
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search"/>
|
||||||
<span class="input-group-text py-1">
|
<span class="input-group-text py-1">
|
||||||
<a href="#" id="quicksearch_clear" class="invisible text-secondary"><i class="mdi mdi-close-circle"></i></a>
|
<a href="#" id="quicksearch_clear" class="invisible text-secondary"><i class="mdi mdi-close-circle"></i></a>
|
||||||
</span>
|
</span>
|
||||||
{% block extra_table_controls %}{% endblock %}
|
{% block extra_table_controls %}{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="col-auto d-print-none">
|
||||||
|
<div class="input-group">
|
||||||
|
<div class="input-group-text">
|
||||||
|
<i class="mdi mdi-filter" title="{% trans "Saved filter" %}"></i>
|
||||||
|
</div>
|
||||||
|
{{ filter_form.filter_id }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="col-auto ms-auto d-print-none">
|
<div class="col-auto ms-auto d-print-none">
|
||||||
{% if request.user.is_authenticated and table_modal %}
|
{% if request.user.is_authenticated and table_modal %}
|
||||||
<div class="table-configure input-group">
|
<div class="table-configure input-group">
|
||||||
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}" data-bs-target="#{{ table_modal }}"
|
<button type="button" data-bs-toggle="modal" title="{% trans "Configure Table" %}"
|
||||||
class="btn">
|
data-bs-target="#{{ table_modal }}"
|
||||||
|
class="btn">
|
||||||
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
|
<i class="mdi mdi-cog"></i> {% trans "Configure Table" %}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@ -4,8 +4,7 @@ from django.utils.translation import gettext as _
|
|||||||
|
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.relations import get_related_models
|
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||||
from utilities.views import register_model_view, ViewTab
|
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -56,17 +55,14 @@ class TenantGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(TenantGroup)
|
@register_model_view(TenantGroup)
|
||||||
class TenantGroupView(generic.ObjectView):
|
class TenantGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = TenantGroup.objects.all()
|
queryset = TenantGroup.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
groups = instance.get_descendants(include_self=True)
|
groups = instance.get_descendants(include_self=True)
|
||||||
related_models = (
|
|
||||||
(Tenant.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, groups),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -123,17 +119,12 @@ class TenantListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(Tenant)
|
@register_model_view(Tenant)
|
||||||
class TenantView(generic.ObjectView):
|
class TenantView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = Tenant.objects.all()
|
queryset = Tenant.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = [
|
|
||||||
(model.objects.restrict(request.user, 'view').filter(tenant=instance), f'{field}_id')
|
|
||||||
for model, field in get_related_models(Tenant)
|
|
||||||
]
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -189,17 +180,14 @@ class ContactGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ContactGroup)
|
@register_model_view(ContactGroup)
|
||||||
class ContactGroupView(generic.ObjectView):
|
class ContactGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ContactGroup.objects.all()
|
queryset = ContactGroup.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
groups = instance.get_descendants(include_self=True)
|
groups = instance.get_descendants(include_self=True)
|
||||||
related_models = (
|
|
||||||
(Contact.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, groups),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -256,16 +244,12 @@ class ContactRoleListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ContactRole)
|
@register_model_view(ContactRole)
|
||||||
class ContactRoleView(generic.ObjectView):
|
class ContactRoleView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ContactRole.objects.all()
|
queryset = ContactRole.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(ContactAssignment.objects.restrict(request.user, 'view').filter(role=instance), 'role_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -39,7 +39,7 @@ def handle_protectederror(obj_list, request, e):
|
|||||||
if hasattr(dependent, 'get_absolute_url'):
|
if hasattr(dependent, 'get_absolute_url'):
|
||||||
dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
|
dependent_objects.append(f'<a href="{dependent.get_absolute_url()}">{escape(dependent)}</a>')
|
||||||
else:
|
else:
|
||||||
dependent_objects.append(str(dependent))
|
dependent_objects.append(escape(str(dependent)))
|
||||||
err_message += ', '.join(dependent_objects)
|
err_message += ', '.join(dependent_objects)
|
||||||
|
|
||||||
messages.error(request, mark_safe(err_message))
|
messages.error(request, mark_safe(err_message))
|
||||||
|
@ -29,7 +29,7 @@ def linkify_phone(value):
|
|||||||
"""
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
return f"tel:{value}"
|
return f"tel:{value.replace(' ', '')}"
|
||||||
|
|
||||||
|
|
||||||
def register_table_column(column, name, *tables):
|
def register_table_column(column, name, *tables):
|
||||||
|
@ -11,7 +11,7 @@
|
|||||||
{% elif customfield.type == 'date' and value %}
|
{% elif customfield.type == 'date' and value %}
|
||||||
{{ value|isodate }}
|
{{ value|isodate }}
|
||||||
{% elif customfield.type == 'datetime' and value %}
|
{% elif customfield.type == 'datetime' and value %}
|
||||||
{{ value|isodate }} {{ value|isodatetime }}
|
{{ value|isodatetime }}
|
||||||
{% elif customfield.type == 'url' and value %}
|
{% elif customfield.type == 'url' and value %}
|
||||||
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
<a href="{{ value }}">{{ value|truncatechars:70 }}</a>
|
||||||
{% elif customfield.type == 'json' and value %}
|
{% elif customfield.type == 'json' and value %}
|
||||||
|
@ -1,7 +1,23 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load i18n %}
|
||||||
{% load navigation %}
|
{% load navigation %}
|
||||||
|
|
||||||
<ul class="navbar-nav pt-lg-2" {% htmx_boost %}>
|
<ul class="navbar-nav pt-lg-2" {% htmx_boost %}>
|
||||||
|
<li class="nav-item d-block d-lg-none">
|
||||||
|
<form action="{% url 'search' %}" method="get" autocomplete="off" novalidate>
|
||||||
|
<div class="input-group mb-1 mt-2">
|
||||||
|
<div class="input-group-prepend">
|
||||||
|
<span class="input-group-text">
|
||||||
|
<i class="mdi mdi-magnify"></i>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input type="text" name="q" value="" class="form-control" placeholder="{% trans "Search…" %}" aria-label="{% trans "Search NetBox" %}">
|
||||||
|
<div class="input-group-append">
|
||||||
|
<button type="submit" class="form-control">{% trans "Search" %}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</li>
|
||||||
{% for menu, groups in nav_items %}
|
{% for menu, groups in nav_items %}
|
||||||
<li class="nav-item dropdown">
|
<li class="nav-item dropdown">
|
||||||
|
|
||||||
|
@ -58,7 +58,7 @@ def linkify(instance, attr=None):
|
|||||||
url = instance.get_absolute_url()
|
url = instance.get_absolute_url()
|
||||||
return mark_safe(f'<a href="{url}">{escape(text)}</a>')
|
return mark_safe(f'<a href="{url}">{escape(text)}</a>')
|
||||||
except (AttributeError, TypeError):
|
except (AttributeError, TypeError):
|
||||||
return text
|
return escape(text)
|
||||||
|
|
||||||
|
|
||||||
@register.filter()
|
@register.filter()
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
from extras.choices import CustomFieldTypeChoices
|
from extras.choices import CustomFieldTypeChoices
|
||||||
from utilities.querydict import dict_to_querydict
|
from utilities.querydict import dict_to_querydict
|
||||||
@ -124,5 +125,5 @@ def formaction(context):
|
|||||||
if HTMX navigation is enabled (per the user's preferences).
|
if HTMX navigation is enabled (per the user's preferences).
|
||||||
"""
|
"""
|
||||||
if context.get('htmx_navigation', False):
|
if context.get('htmx_navigation', False):
|
||||||
return 'hx-push-url="true" hx-post'
|
return mark_safe('hx-push-url="true" hx-post')
|
||||||
return 'formaction'
|
return 'formaction'
|
||||||
|
@ -281,6 +281,10 @@ def applied_filters(context, model, form, query_params):
|
|||||||
if filter_name not in querydict:
|
if filter_name not in querydict:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip saved filters, as they're displayed alongside the quick search widget
|
||||||
|
if filter_name == 'filter_id':
|
||||||
|
continue
|
||||||
|
|
||||||
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
|
bound_field = form.fields[filter_name].get_bound_field(form, filter_name)
|
||||||
querydict.pop(filter_name)
|
querydict.pop(filter_name)
|
||||||
display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
|
display_value = ', '.join([str(v) for v in get_selected_values(form, filter_name)])
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from django import template
|
from django import template
|
||||||
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
@ -15,6 +16,6 @@ def nested_tree(obj):
|
|||||||
nodes = obj.get_ancestors(include_self=True)
|
nodes = obj.get_ancestors(include_self=True)
|
||||||
return mark_safe(
|
return mark_safe(
|
||||||
' / '.join(
|
' / '.join(
|
||||||
f'<a href="{node.get_absolute_url()}">{node}</a>' for node in nodes
|
f'<a href="{node.get_absolute_url()}">{escape(node)}</a>' for node in nodes
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
@ -1,15 +1,20 @@
|
|||||||
|
from typing import Iterable
|
||||||
|
|
||||||
from django.contrib.auth.mixins import AccessMixin
|
from django.contrib.auth.mixins import AccessMixin
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.urls.exceptions import NoReverseMatch
|
from django.urls.exceptions import NoReverseMatch
|
||||||
|
from django.utils.http import url_has_allowed_host_and_scheme
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from netbox.plugins import PluginConfig
|
from netbox.plugins import PluginConfig
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
|
from utilities.relations import get_related_models
|
||||||
from .permissions import resolve_permission
|
from .permissions import resolve_permission
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'ContentTypePermissionRequiredMixin',
|
'ContentTypePermissionRequiredMixin',
|
||||||
|
'GetRelatedModelsMixin',
|
||||||
'GetReturnURLMixin',
|
'GetReturnURLMixin',
|
||||||
'ObjectPermissionRequiredMixin',
|
'ObjectPermissionRequiredMixin',
|
||||||
'ViewTab',
|
'ViewTab',
|
||||||
@ -119,7 +124,7 @@ class GetReturnURLMixin:
|
|||||||
# First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
|
# First, see if `return_url` was specified as a query parameter or form data. Use this URL only if it's
|
||||||
# considered safe.
|
# considered safe.
|
||||||
return_url = request.GET.get('return_url') or request.POST.get('return_url')
|
return_url = request.GET.get('return_url') or request.POST.get('return_url')
|
||||||
if return_url and return_url.startswith('/'):
|
if return_url and url_has_allowed_host_and_scheme(return_url, allowed_hosts=None):
|
||||||
return return_url
|
return return_url
|
||||||
|
|
||||||
# Next, check if the object being modified (if any) has an absolute URL.
|
# Next, check if the object being modified (if any) has an absolute URL.
|
||||||
@ -142,6 +147,46 @@ class GetReturnURLMixin:
|
|||||||
return reverse('home')
|
return reverse('home')
|
||||||
|
|
||||||
|
|
||||||
|
class GetRelatedModelsMixin:
|
||||||
|
"""
|
||||||
|
Provides logic for collecting all related models for the currently viewed model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def get_related_models(self, request, instance, omit=[], extra=[]):
|
||||||
|
"""
|
||||||
|
Get related models of the view's `queryset` model without those listed in `omit`. Will be sorted alphabetical.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request: Current request being processed.
|
||||||
|
instance: The instance related models should be looked up for. A list of instances can be passed to match
|
||||||
|
related objects in this list (e.g. to find sites of a region including child regions).
|
||||||
|
omit: Remove relationships to these models from the result. Needs to be passed, if related models don't
|
||||||
|
provide a `_list` view.
|
||||||
|
extra: Add extra models to the list of automatically determined related models. Can be used to add indirect
|
||||||
|
relationships.
|
||||||
|
"""
|
||||||
|
model = self.queryset.model
|
||||||
|
related = filter(
|
||||||
|
lambda m: m[0] is not model and m[0] not in omit,
|
||||||
|
get_related_models(model, False)
|
||||||
|
)
|
||||||
|
|
||||||
|
related_models = [
|
||||||
|
(
|
||||||
|
model.objects.restrict(request.user, 'view').filter(**(
|
||||||
|
{f'{field}__in': instance}
|
||||||
|
if isinstance(instance, Iterable)
|
||||||
|
else {field: instance}
|
||||||
|
)),
|
||||||
|
f'{field}_id'
|
||||||
|
)
|
||||||
|
for model, field in related
|
||||||
|
]
|
||||||
|
related_models.extend(extra)
|
||||||
|
|
||||||
|
return sorted(related_models, key=lambda x: x[0].model._meta.verbose_name.lower())
|
||||||
|
|
||||||
|
|
||||||
class ViewTab:
|
class ViewTab:
|
||||||
"""
|
"""
|
||||||
ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
|
ViewTabs are used for navigation among multiple object-specific views, such as the changelog or journal for
|
||||||
|
@ -178,8 +178,8 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
selector=True,
|
selector=True,
|
||||||
query_params={
|
query_params={
|
||||||
'site_id': '$site',
|
'site_id': ['$site', 'null']
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
device = DynamicModelChoiceField(
|
device = DynamicModelChoiceField(
|
||||||
label=_('Device'),
|
label=_('Device'),
|
||||||
|
@ -180,7 +180,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
|
|||||||
})
|
})
|
||||||
|
|
||||||
# Validate site for cluster & device
|
# Validate site for cluster & device
|
||||||
if self.cluster and self.site and self.cluster.site != self.site:
|
if self.cluster and self.cluster.site is not None and self.cluster.site != self.site:
|
||||||
raise ValidationError({
|
raise ValidationError({
|
||||||
'cluster': _(
|
'cluster': _(
|
||||||
'The selected cluster ({cluster}) is not assigned to this site ({site}).'
|
'The selected cluster ({cluster}) is not assigned to this site ({site}).'
|
||||||
|
@ -63,6 +63,9 @@ class VirtualMachineTestCase(TestCase):
|
|||||||
# VM with site only should pass
|
# VM with site only should pass
|
||||||
VirtualMachine(name='vm1', site=sites[0]).full_clean()
|
VirtualMachine(name='vm1', site=sites[0]).full_clean()
|
||||||
|
|
||||||
|
# VM with site, cluster non-site should pass
|
||||||
|
VirtualMachine(name='vm1', site=sites[0], cluster=clusters[2]).full_clean()
|
||||||
|
|
||||||
# VM with non-site cluster only should pass
|
# VM with non-site cluster only should pass
|
||||||
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
|
VirtualMachine(name='vm1', cluster=clusters[2]).full_clean()
|
||||||
|
|
||||||
|
@ -20,7 +20,7 @@ from netbox.views import generic
|
|||||||
from tenancy.views import ObjectContactsView
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.query_functions import CollateAsChar
|
from utilities.query_functions import CollateAsChar
|
||||||
from utilities.views import ViewTab, register_model_view
|
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -39,16 +39,12 @@ class ClusterTypeListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ClusterType)
|
@register_model_view(ClusterType)
|
||||||
class ClusterTypeView(generic.ObjectView):
|
class ClusterTypeView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ClusterType.objects.all()
|
queryset = ClusterType.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Cluster.objects.restrict(request.user, 'view').filter(type=instance), 'type_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -99,16 +95,12 @@ class ClusterGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(ClusterGroup)
|
@register_model_view(ClusterGroup)
|
||||||
class ClusterGroupView(generic.ObjectView):
|
class ClusterGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = ClusterGroup.objects.all()
|
queryset = ClusterGroup.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Cluster.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -2,7 +2,7 @@ from ipam.tables import RouteTargetTable
|
|||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from tenancy.views import ObjectContactsView
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.views import register_model_view
|
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -21,16 +21,12 @@ class TunnelGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(TunnelGroup)
|
@register_model_view(TunnelGroup)
|
||||||
class TunnelGroupView(generic.ObjectView):
|
class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = TunnelGroup.objects.all()
|
queryset = TunnelGroup.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
related_models = (
|
|
||||||
(Tunnel.objects.restrict(request.user, 'view').filter(group=instance), 'group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, instance),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.views import register_model_view
|
from utilities.views import GetRelatedModelsMixin, register_model_view
|
||||||
from . import filtersets, forms, tables
|
from . import filtersets, forms, tables
|
||||||
from .models import *
|
from .models import *
|
||||||
|
|
||||||
@ -24,17 +24,14 @@ class WirelessLANGroupListView(generic.ObjectListView):
|
|||||||
|
|
||||||
|
|
||||||
@register_model_view(WirelessLANGroup)
|
@register_model_view(WirelessLANGroup)
|
||||||
class WirelessLANGroupView(generic.ObjectView):
|
class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||||
queryset = WirelessLANGroup.objects.all()
|
queryset = WirelessLANGroup.objects.all()
|
||||||
|
|
||||||
def get_extra_context(self, request, instance):
|
def get_extra_context(self, request, instance):
|
||||||
groups = instance.get_descendants(include_self=True)
|
groups = instance.get_descendants(include_self=True)
|
||||||
related_models = (
|
|
||||||
(WirelessLAN.objects.restrict(request.user, 'view').filter(group__in=groups), 'group_id'),
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'related_models': related_models,
|
'related_models': self.get_related_models(request, groups),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,26 +1,26 @@
|
|||||||
Django==5.0.6
|
Django==5.0.6
|
||||||
django-cors-headers==4.3.1
|
django-cors-headers==4.4.0
|
||||||
django-debug-toolbar==4.4.2
|
django-debug-toolbar==4.3.0
|
||||||
django-filter==24.2
|
django-filter==24.2
|
||||||
django-htmx==1.17.3
|
django-htmx==1.18.0
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.16.0
|
django-mptt==0.16.0
|
||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
django-prometheus==2.3.1
|
django-prometheus==2.3.1
|
||||||
django-redis==5.4.0
|
django-redis==5.4.0
|
||||||
django-rich==1.8.0
|
django-rich==1.9.0
|
||||||
django-rq==2.10.2
|
django-rq==2.10.2
|
||||||
django-taggit==5.0.1
|
django-taggit==5.0.1
|
||||||
django-tables2==2.7.0
|
django-tables2==2.7.0
|
||||||
django-timezone-field==6.1.0
|
django-timezone-field==6.1.0
|
||||||
djangorestframework==3.15.1
|
djangorestframework==3.15.2
|
||||||
drf-spectacular==0.27.2
|
drf-spectacular==0.27.2
|
||||||
drf-spectacular-sidecar==2024.6.1
|
drf-spectacular-sidecar==2024.6.1
|
||||||
feedparser==6.0.11
|
feedparser==6.0.11
|
||||||
gunicorn==22.0.0
|
gunicorn==22.0.0
|
||||||
Jinja2==3.1.4
|
Jinja2==3.1.4
|
||||||
Markdown==3.6
|
Markdown==3.6
|
||||||
mkdocs-material==9.5.26
|
mkdocs-material==9.5.27
|
||||||
mkdocstrings[python-legacy]==0.25.1
|
mkdocstrings[python-legacy]==0.25.1
|
||||||
netaddr==1.3.0
|
netaddr==1.3.0
|
||||||
nh3==0.2.17
|
nh3==0.2.17
|
||||||
@ -30,8 +30,8 @@ PyYAML==6.0.1
|
|||||||
requests==2.32.3
|
requests==2.32.3
|
||||||
social-auth-app-django==5.4.1
|
social-auth-app-django==5.4.1
|
||||||
social-auth-core==4.5.4
|
social-auth-core==4.5.4
|
||||||
strawberry-graphql==0.234.0
|
strawberry-graphql==0.235.0
|
||||||
strawberry-graphql-django==0.42.0
|
strawberry-graphql-django==0.44.2
|
||||||
svgwrite==1.4.3
|
svgwrite==1.4.3
|
||||||
tablib==3.6.1
|
tablib==3.6.1
|
||||||
tzdata==2024.1
|
tzdata==2024.1
|
||||||
|
Loading…
Reference in New Issue
Block a user