Merge branch 'feature' into 15574-graphql-filter

This commit is contained in:
Jeremy Stretch 2024-03-29 13:20:24 -04:00 committed by GitHub
commit 36e30e4491
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
68 changed files with 381 additions and 172 deletions

View File

@ -19,9 +19,8 @@ django-filter
django-htmx django-htmx
# Modified Preorder Tree Traversal (recursive nesting of objects) # Modified Preorder Tree Traversal (recursive nesting of objects)
# Pinned to 0.14.0; 0.15.0 requires Python 3.9+
# https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst # https://github.com/django-mptt/django-mptt/blob/main/CHANGELOG.rst
django-mptt==0.14.0 django-mptt
# Context managers for PostgreSQL advisory locks # Context managers for PostgreSQL advisory locks
# https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt # https://github.com/Xof/django-pglocks/blob/master/CHANGES.txt

View File

@ -12,8 +12,12 @@ Group=netbox
PIDFile=/var/tmp/netbox.pid PIDFile=/var/tmp/netbox.pid
WorkingDirectory=/opt/netbox WorkingDirectory=/opt/netbox
# Remove the following line if using uWSGI instead of Gunicorn
ExecStart=/opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi ExecStart=/opt/netbox/venv/bin/gunicorn --pid /var/tmp/netbox.pid --pythonpath /opt/netbox/netbox --config /opt/netbox/gunicorn.py netbox.wsgi
# Uncomment the following line if using uWSGI instead of Gunicorn
#ExecStart=/opt/netbox/venv/bin/uwsgi --ini /opt/netbox/uwsgi.ini
Restart=on-failure Restart=on-failure
RestartSec=30 RestartSec=30
PrivateTmp=true PrivateTmp=true

View File

@ -14,10 +14,20 @@ server {
} }
location / { location / {
# Remove these lines if using uWSGI instead of Gunicorn
proxy_pass http://127.0.0.1:8001; proxy_pass http://127.0.0.1:8001;
proxy_set_header X-Forwarded-Host $http_host; proxy_set_header X-Forwarded-Host $http_host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
# Uncomment these lines if using uWSGI instead of Gunicorn
# include uwsgi_params;
# uwsgi_pass 127.0.0.1:8001;
# uwsgi_param Host $host;
# uwsgi_param X-Real-IP $remote_addr;
# uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
# uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto;
} }
} }

18
contrib/uwsgi.ini Normal file
View File

@ -0,0 +1,18 @@
[uwsgi]
; bind to the specified UNIX/TCP socket and port (usually localhost)
socket = 127.0.0.1:8001
; fail to start if any parameter in the configuration file isnt explicitly understood by uWSGI.
strict = true
; re-spawn and pre-fork workers
master = true
; clear environment on exit
vacuum = true
; exit if no app can be loaded
need-app = true
; do not use multiple interpreters
single-interpreter = true

View File

@ -1,10 +1,13 @@
# Gunicorn # Gunicorn
Like most Django applications, NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) (which is automatically installed with NetBox) for this role, however other WSGI servers are available and should work similarly well. [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) is a popular alternative. !!! tip
This page provides instructions for setting up the [gunicorn](http://gunicorn.org/) WSGI server. If you plan to use [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) instead, go [here](./4b-uwsgi.md).
NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [gunicorn](http://gunicorn.org/) (which is automatically installed with NetBox) for this role, however other WSGI servers are available and should work similarly well.
## Configuration ## Configuration
NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any local changes to it do not get overwritten by a future upgrade.) NetBox ships with a default configuration file for gunicorn. To use it, copy `/opt/netbox/contrib/gunicorn.py` to `/opt/netbox/gunicorn.py`. (We make a copy of this file rather than pointing to it directly to ensure that any local changes to it do not get overwritten during a future NetBox upgrade.)
```no-highlight ```no-highlight
sudo cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py sudo cp /opt/netbox/contrib/gunicorn.py /opt/netbox/gunicorn.py

View File

@ -0,0 +1,104 @@
# uWSGI
!!! tip
This page provides instructions for setting up the [uWSGI](https://uwsgi-docs.readthedocs.io/) WSGI server. If you plan to use [gunicorn](http://gunicorn.org/) instead, go [here](./4a-gunicorn.md).
NetBox runs as a [WSGI application](https://en.wikipedia.org/wiki/Web_Server_Gateway_Interface) behind an HTTP server. This documentation shows how to install and configure [uWSGI](https://uwsgi-docs.readthedocs.io/en/latest/) for this role, however other WSGI servers are available and should work similarly well.
## Installation
Activate the Python virtual environment and install the `pyuwsgi` package using pip:
```no-highlight
source /opt/netbox/venv/bin/activate
pip3 install pyuwsgi
```
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
```no-highlight
sudo sh -c "echo 'pyuwgsi' >> /opt/netbox/local_requirements.txt"
```
## Configuration
NetBox ships with a default configuration file for uWSGI. To use it, copy `/opt/netbox/contrib/uwsgi.ini` to `/opt/netbox/uwsgi.ini`. (We make a copy of this file rather than pointing to it directly to ensure that any local changes to it do not get overwritten during a future NetBox upgrade.)
```no-highlight
sudo cp /opt/netbox/contrib/uwsgi.ini /opt/netbox/uwsgi.ini
```
While the provided configuration should suffice for most initial installations, you may wish to edit this file to change the bound IP address and/or port number, or to make performance-related adjustments. See [the uWSGI documentation](https://uwsgi-docs-additions.readthedocs.io/en/latest/Options.html) for the available configuration parameters and take a minute to review the [Things to know](https://uwsgi-docs.readthedocs.io/en/latest/ThingsToKnow.html) page. Django also provides [additional documentation](https://docs.djangoproject.com/en/5.0/howto/deployment/wsgi/uwsgi/) on configuring uWSGI with a Django app.
## systemd Setup
We'll use systemd to control both uWSGI and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory.
```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/
sudo systemctl daemon-reload
```
The reference configuration assumes that gunicorn is in use, so we need to update it. Edit the `netbox.service` file to remove the line beginning with `ExecStart=/opt/netbox/venv/bin/gunicorn` and uncomment the line below it.
!!! warning "Check user & group assignment"
The stock service configuration files packaged with NetBox assume that the service will run with the `netbox` user and group names. If these differ on your installation, be sure to update the service files accordingly.
Once the configuration file has been saved, reload the service:
```no-highlight
sudo systemctl daemon-reload
```
Then, start the `netbox` and `netbox-rq` services and enable them to initiate at boot time:
```no-highlight
sudo systemctl enable --now netbox netbox-rq
```
You can use the command `systemctl status netbox` to verify that the WSGI service is running:
```no-highlight
systemctl status netbox.service
```
You should see output similar to the following:
```no-highlight
● netbox.service - NetBox WSGI Service
Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
Docs: https://docs.netbox.dev/
Main PID: 1140492 (uwsgi)
Tasks: 19 (limit: 4683)
Memory: 666.2M
CGroup: /system.slice/netbox.service
├─1061 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/uwsgi --ini /opt/netbox/uwsgi.ini
├─1976 /opt/netbox/venv/bin/python3 /opt/netbox/venv/bin/uwsgi --ini /opt/netbox/uwsgi.ini
...
```
!!! note
If the NetBox service fails to start, issue the command `journalctl -eu netbox` to check for log messages that may indicate the problem.
Once you've verified that the WSGI workers are up and running, move on to HTTP server setup.
## HTTP Server Installation
For server installation, you will want to follow the NetBox [HTTP Server Setup](5-http-server.md) guide, however after copying the configuration file, you will need to edit the file and change the `location` section to uncomment the uWSGI parameters:
```no-highlight
location / {
# proxy_pass http://127.0.0.1:8001;
# proxy_set_header X-Forwarded-Host $http_host;
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-Proto $scheme;
# comment the lines above and uncomment the lines below if using uWSGI
include uwsgi_params;
uwsgi_pass 127.0.0.1:8001;
uwsgi_param Host $host;
uwsgi_param X-Real-IP $remote_addr;
uwsgi_param X-Forwarded-For $proxy_add_x_forwarded_for;
uwsgi_param X-Forwarded-Proto $http_x_forwarded_proto;
}
```

View File

@ -35,6 +35,9 @@ Once nginx is installed, copy the nginx configuration file provided by NetBox to
sudo cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox sudo cp /opt/netbox/contrib/nginx.conf /etc/nginx/sites-available/netbox
``` ```
!!! tip "gunicorn vs. uWSGI"
The reference nginx configuration file assumes that gunicorn is in use. If using uWSGI instead, you'll need to remove the gunicorn-specific configuration (lines beginning with `proxy_pass` and `proxy_set_header`) and uncomment the uWSGI section below them before proceeding.
Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created. Then, delete `/etc/nginx/sites-enabled/default` and create a symlink in the `sites-enabled` directory to the configuration file you just created.
```no-highlight ```no-highlight

View File

@ -94,7 +94,8 @@ nav:
- 1. PostgreSQL: 'installation/1-postgresql.md' - 1. PostgreSQL: 'installation/1-postgresql.md'
- 2. Redis: 'installation/2-redis.md' - 2. Redis: 'installation/2-redis.md'
- 3. NetBox: 'installation/3-netbox.md' - 3. NetBox: 'installation/3-netbox.md'
- 4. Gunicorn: 'installation/4-gunicorn.md' - 4a. Gunicorn: 'installation/4a-gunicorn.md'
- 4b. uWSGI: 'installation/4b-uwsgi.md'
- 5. HTTP Server: 'installation/5-http-server.md' - 5. HTTP Server: 'installation/5-http-server.md'
- 6. LDAP (Optional): 'installation/6-ldap.md' - 6. LDAP (Optional): 'installation/6-ldap.md'
- Upgrading NetBox: 'installation/upgrading.md' - Upgrading NetBox: 'installation/upgrading.md'

View File

@ -156,8 +156,6 @@ class NetBoxAutoSchema(AutoSchema):
remove_fields.append(child_name) remove_fields.append(child_name)
if isinstance(child, (ChoiceField, WritableNestedSerializer)): if isinstance(child, (ChoiceField, WritableNestedSerializer)):
properties[child_name] = None properties[child_name] = None
elif isinstance(child, ManyRelatedField) and isinstance(child.child_relation, SerializedPKRelatedField):
properties[child_name] = None
if not properties: if not properties:
return None return None

View File

@ -25,6 +25,7 @@ from netbox.views import generic
from netbox.views.generic.base import BaseObjectView from netbox.views.generic.base import BaseObjectView
from netbox.views.generic.mixins import TableMixin 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.query import count_related from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -320,7 +321,7 @@ class BackgroundTaskListView(TableMixin, BaseRQView):
table = self.get_table(data, request, False) table = self.get_table(data, request, False)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if request.htmx: if htmx_partial(request):
return render(request, 'htmx/table.html', { return render(request, 'htmx/table.html', {
'table': table, 'table': table,
}) })
@ -489,8 +490,8 @@ class WorkerListView(TableMixin, BaseRQView):
table = self.get_table(data, request, False) table = self.get_table(data, request, False)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if request.htmx: if htmx_partial(request):
if request.htmx.target != 'object_list': if not request.htmx.target:
table.embedded = True table.embedded = True
# Hide selection checkboxes # Hide selection checkboxes
if 'pk' in table.base_columns: if 'pk' in table.base_columns:

View File

@ -1,3 +1,5 @@
import decimal
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from rest_framework import serializers from rest_framework import serializers
@ -22,7 +24,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
max_digits=4, max_digits=4,
decimal_places=1, decimal_places=1,
label=_('Position (U)'), label=_('Position (U)'),
min_value=0, min_value=decimal.Decimal(0),
default=1.0 default=1.0
) )
subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False, allow_null=True)

View File

@ -20,6 +20,7 @@ from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
from utilities.htmx import htmx_partial
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
from utilities.query import count_related from utilities.query import count_related
from utilities.querydict import normalize_querydict from utilities.querydict import normalize_querydict
@ -1224,7 +1225,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
} }
# If this is an HTMX request, return only the result HTML # If this is an HTMX request, return only the result HTML
if request.htmx: if htmx_partial(request):
response = render(request, 'extras/htmx/script_result.html', context) response = render(request, 'extras/htmx/script_result.html', context)
if job.completed or not job.started: if job.completed or not job.started:
response.status_code = 286 response.status_code = 286

View File

@ -27,9 +27,13 @@ class BaseModelSerializer(serializers.ModelSerializer):
self.nested = nested self.nested = nested
self._requested_fields = fields self._requested_fields = fields
# Disable validators for nested objects (which already exist)
if self.nested:
self.validators = []
# If this serializer is nested but no fields have been specified, # If this serializer is nested but no fields have been specified,
# default to using Meta.brief_fields (if set) # default to using Meta.brief_fields (if set)
if nested and not fields: if self.nested and not fields:
self._requested_fields = getattr(self.Meta, 'brief_fields', None) self._requested_fields = getattr(self.Meta, 'brief_fields', None)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)

View File

@ -8,9 +8,11 @@ def settings_and_registry(request):
""" """
Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }} Expose Django settings and NetBox registry stores in the template context. Example: {{ settings.DEBUG }}
""" """
user_preferences = request.user.config if request.user.is_authenticated else {}
return { return {
'settings': django_settings, 'settings': django_settings,
'config': get_config(), 'config': get_config(),
'registry': registry, 'registry': registry,
'preferences': request.user.config if request.user.is_authenticated else {}, 'preferences': user_preferences,
'htmx_navigation': user_preferences.get('ui.htmx_navigation', False) == 'true'
} }

View File

@ -23,6 +23,14 @@ PREFERENCES = {
), ),
default='light', default='light',
), ),
'ui.htmx_navigation': UserPreference(
label=_('HTMX Navigation'),
choices=(
('', _('Disabled')),
('true', _('Enabled')),
),
default=False
),
'locale.language': UserPreference( 'locale.language': UserPreference(
label=_('Language'), label=_('Language'),
choices=( choices=(

View File

@ -23,6 +23,7 @@ from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.views import GetReturnURLMixin, get_viewname from utilities.views import GetReturnURLMixin, get_viewname
from .base import BaseMultiObjectView from .base import BaseMultiObjectView
@ -161,8 +162,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
table = self.get_table(self.queryset, request, has_bulk_actions) table = self.get_table(self.queryset, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if request.htmx: if htmx_partial(request):
if request.htmx.target != 'object_list': if not request.htmx.target:
table.embedded = True table.embedded = True
# Hide selection checkboxes # Hide selection checkboxes
if 'pk' in table.base_columns: if 'pk' in table.base_columns:

View File

@ -17,6 +17,7 @@ from extras.signals import clear_events
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.forms import ConfirmationForm, restrict_form_fields
from utilities.htmx import htmx_partial
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.querydict import normalize_querydict, prepare_cloned_fields from utilities.querydict import normalize_querydict, prepare_cloned_fields
from utilities.views import GetReturnURLMixin, get_viewname from utilities.views import GetReturnURLMixin, get_viewname
@ -138,7 +139,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table = self.get_table(table_data, request, has_bulk_actions) table = self.get_table(table_data, request, has_bulk_actions)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if request.htmx: if htmx_partial(request):
return render(request, 'htmx/table.html', { return render(request, 'htmx/table.html', {
'object': instance, 'object': instance,
'table': table, 'table': table,
@ -226,7 +227,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
restrict_form_fields(form, request.user) restrict_form_fields(form, request.user)
# If this is an HTMX request, return only the rendered form HTML # If this is an HTMX request, return only the rendered form HTML
if request.htmx: if htmx_partial(request):
return render(request, 'htmx/form.html', { return render(request, 'htmx/form.html', {
'form': form, 'form': form,
}) })
@ -482,7 +483,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
instance = self.alter_object(self.queryset.model(), request) instance = self.alter_object(self.queryset.model(), request)
# If this is an HTMX request, return only the rendered form HTML # If this is an HTMX request, return only the rendered form HTML
if request.htmx: if htmx_partial(request):
return render(request, 'htmx/form.html', { return render(request, 'htmx/form.html', {
'form': form, 'form': form,
}) })

View File

@ -17,6 +17,7 @@ from netbox.forms import SearchForm
from netbox.search import LookupTypes from netbox.search import LookupTypes
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from netbox.tables import SearchTable from netbox.tables import SearchTable
from utilities.htmx import htmx_partial
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
__all__ = ( __all__ = (
@ -104,7 +105,7 @@ class SearchView(View):
}).configure(table) }).configure(table)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if request.htmx: if htmx_partial(request):
return render(request, 'htmx/table.html', { return render(request, 'htmx/table.html', {
'table': table, 'table': table,
}) })

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,11 +1,12 @@
import { getElements, isTruthy } from './util';
import { initButtons } from './buttons'; import { initButtons } from './buttons';
import { initClipboard } from './clipboard'
import { initSelects } from './select'; import { initSelects } from './select';
import { initObjectSelector } from './objectSelector'; import { initObjectSelector } from './objectSelector';
import { initBootstrap } from './bs'; import { initBootstrap } from './bs';
import { initMessages } from './messages';
function initDepedencies(): void { function initDepedencies(): void {
for (const init of [initButtons, initSelects, initObjectSelector, initBootstrap]) { for (const init of [initButtons, initClipboard, initSelects, initObjectSelector, initBootstrap, initMessages]) {
init(); init();
} }
} }
@ -15,16 +16,5 @@ function initDepedencies(): void {
* elements. * elements.
*/ */
export function initHtmx(): void { export function initHtmx(): void {
for (const element of getElements('[hx-target]')) { document.addEventListener('htmx:afterSettle', initDepedencies);
const targetSelector = element.getAttribute('hx-target');
if (isTruthy(targetSelector)) {
for (const target of getElements(targetSelector)) {
target.addEventListener('htmx:afterSettle', initDepedencies);
}
}
}
for (const element of getElements('[hx-trigger=load]')) {
element.addEventListener('htmx:afterSettle', initDepedencies);
}
} }

View File

@ -5,6 +5,7 @@
@import '../node_modules/@tabler/core/src/scss/vendor/tom-select'; @import '../node_modules/@tabler/core/src/scss/vendor/tom-select';
// Overrides of external libraries // Overrides of external libraries
@import 'overrides/bootstrap';
@import 'overrides/tabler'; @import 'overrides/tabler';
// Transitional styling to ease migration of templates from NetBox v3.x // Transitional styling to ease migration of templates from NetBox v3.x

View File

@ -0,0 +1,11 @@
// Disable smooth scrolling for intra-page links
html {
scroll-behavior: auto !important;
}
// Prevent dropdown menus from being clipped inside responsive tables
.table-responsive {
.dropdown, .btn-group, .btn-group-vertical {
position: static;
}
}

View File

@ -6,7 +6,7 @@
{% block title %}{% trans "User Preferences" %}{% endblock %} {% block title %}{% trans "User Preferences" %}{% endblock %}
{% block content %} {% block content %}
<form method="post" action="" id="preferences-update"> <form method="post" action="" hx-disable="true" id="preferences-update">
{% csrf_token %} {% csrf_token %}
{# Built-in preferences #} {# Built-in preferences #}

View File

@ -15,6 +15,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover" /> <meta name="viewport" content="initial-scale=1, maximum-scale=1, user-scalable=no, width=device-width, viewport-fit=cover" />
<meta name="htmx-config" content='{"scrollBehavior": "auto"}'>
{# Page title #} {# Page title #}
<title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title> <title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>

View File

@ -58,8 +58,48 @@ Blocks:
<i class="mdi mdi-lightbulb-on"></i> <i class="mdi mdi-lightbulb-on"></i>
</button> </button>
</div> </div>
{# User menu #} {# User menu #}
{% include 'inc/user_menu.html' %} {% if request.user.is_authenticated %}
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<div class="d-xl-block ps-2">
<div>{{ request.user }}</div>
<div class="mt-1 small text-secondary">{% if request.user.is_staff %}Staff{% else %}User{% endif %}</div>
</div>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow" {% htmx_boost %}>
{% if config.DJANGO_ADMIN_ENABLED and request.user.is_staff %}
<a class="dropdown-item" href="{% url 'admin:index' %}">
<i class="mdi mdi-cog"></i> {% trans "Django Admin" %}
</a>
{% endif %}
<a href="{% url 'account:profile' %}" class="dropdown-item">
<i class="mdi mdi-account"></i> {% trans "Profile" %}
</a>
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
</a>
<a href="{% url 'account:preferences' %}" class="dropdown-item">
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
</a>
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
<i class="mdi mdi-key"></i> {% trans "API Tokens" %}
</a>
<div class="dropdown-divider"></div>
<a href="{% url 'logout' %}" class="dropdown-item">
<i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
</a>
</div>
</div>
{% else %}
<div class="btn-group ps-2">
<a class="btn btn-primary" type="button" href="{% url 'login' %}?next={{ request.path }}">
<i class="mdi mdi-login-variant"></i> {% trans "Log In" %}
</a>
</div>
{% endif %}
{# /User menu #}
</div> </div>
{# Search box #} {# Search box #}
@ -79,6 +119,7 @@ Blocks:
{# Page content #} {# Page content #}
<div class="page-wrapper"> <div class="page-wrapper">
<div id="page-content" {% htmx_boost %}>
{# Page header #} {# Page header #}
{% block header %} {% block header %}
@ -122,6 +163,8 @@ Blocks:
{% endif %} {% endif %}
{# /Bottom banner #} {# /Bottom banner #}
</div>
{# Page footer #} {# Page footer #}
<footer class="footer footer-transparent d-print-none py-2"> <footer class="footer footer-transparent d-print-none py-2">
<div class="container-fluid d-flex justify-content-between align-items-center"> <div class="container-fluid d-flex justify-content-between align-items-center">
@ -173,7 +216,7 @@ Blocks:
{# /Footer links #} {# /Footer links #}
{# Footer text #} {# Footer text #}
<ul class="list-inline list-inline-dots mb-0"> <ul class="list-inline list-inline-dots mb-0" id="footer-stamp" hx-swap-oob="true">
<li class="list-inline-item"> <li class="list-inline-item">
{% annotated_now %} {% now 'T' %} {% annotated_now %} {% now 'T' %}
</li> </li>

View File

@ -10,7 +10,7 @@
{% endif %} {% endif %}
{% if 'bulk_rename' in actions %} {% if 'bulk_rename' in actions %}
{% with bulk_rename_view=model|validated_viewname:"bulk_rename" %} {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
<button type="submit" name="_rename" formaction="{% url bulk_rename_view %}" class="btn btn-outline-warning"> <button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %} <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
</button> </button>
{% endwith %} {% endwith %}

View File

@ -5,7 +5,7 @@
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %} {% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit" <button type="submit" name="_edit"
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}" {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning"> class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button> </button>
@ -14,7 +14,7 @@
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %} {% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename" <button type="submit" name="_rename"
formaction="{% url bulk_rename_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning"> class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button> </button>

View File

@ -7,7 +7,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -7,7 +7,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -7,7 +7,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -11,7 +11,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -7,7 +7,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -7,7 +7,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -7,7 +7,7 @@
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %} {% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %} {% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect" <button type="submit" name="_disconnect"
formaction="{% url bulk_disconnect_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger"> class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>

View File

@ -11,63 +11,63 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %} {% if perms.dcim.add_consoleport %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Console Ports" %} {% trans "Console Ports" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_consoleserverport %} {% if perms.dcim.add_consoleserverport %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item "> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
{% trans "Console Server Ports" %} {% trans "Console Server Ports" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_powerport %} {% if perms.dcim.add_powerport %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Ports" %} {% trans "Power Ports" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_poweroutlet %} {% if perms.dcim.add_poweroutlet %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Outlets" %} {% trans "Power Outlets" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_interface %} {% if perms.dcim.add_interface %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
class="dropdown-item">{% trans "Interfaces" %} {% trans "Interfaces" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_rearport %} {% if perms.dcim.add_rearport %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Rear Ports" %} {% trans "Rear Ports" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_devicebay %} {% if perms.dcim.add_devicebay %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Device Bays" %} {% trans "Device Bays" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_modulebay %} {% if perms.dcim.add_modulebay %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Module Bays" %} {% trans "Module Bays" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.dcim.add_inventoryitem %} {% if perms.dcim.add_inventoryitem %}
<li> <li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Inventory Items" %} {% trans "Inventory Items" %}
</button> </button>
</li> </li>
@ -78,7 +78,7 @@
{% if 'bulk_edit' in actions %} {% if 'bulk_edit' in actions %}
<div class="btn-group" role="group"> <div class="btn-group" role="group">
{% bulk_edit_button model query_params=request.GET %} {% bulk_edit_button model query_params=request.GET %}
<button type="submit" name="_rename" formaction="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning"> <button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %} <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button> </button>
</div> </div>

View File

@ -7,7 +7,7 @@
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %} {% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit" <button type="submit" name="_edit"
formaction="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}" {% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning"> class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button> </button>
@ -16,7 +16,7 @@
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %} {% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename" <button type="submit" name="_rename"
formaction="{% url bulk_rename_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning"> class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button> </button>

View File

@ -3,7 +3,7 @@
<object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation"></object> <object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation"></object>
</div> </div>
<div class="text-center mt-3"> <div class="text-center mt-3">
<a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"> <a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-boost="false">
<i class="mdi mdi-file-download"></i> {% trans "Download SVG" %} <i class="mdi mdi-file-download"></i> {% trans "Download SVG" %}
</a> </a>
</div> </div>

View File

@ -13,13 +13,13 @@
</div> </div>
<div class="card-footer d-print-none"> <div class="card-footer d-print-none">
{% if table.rows %} {% if table.rows %}
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-warning"> <button type="submit" name="_edit" {% formaction %}="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-warning">
<span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %} <span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %}
</button> </button>
<button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-warning"> <button type="submit" name="_edit" {% formaction %}="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %} <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
</button> </button>
<button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-danger"> <button type="submit" name="_delete" {% formaction %}="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-danger">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %} <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -52,17 +52,17 @@
{% htmx_table 'dcim:powerfeed_list' power_panel_id=object.pk %} {% htmx_table 'dcim:powerfeed_list' power_panel_id=object.pk %}
<div class="card-footer d-print-none"> <div class="card-footer d-print-none">
{% if perms.dcim.change_powerfeed %} {% if perms.dcim.change_powerfeed %}
<button type="submit" name="_edit" formaction="{% url 'dcim:powerfeed_bulk_edit' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-warning"> <button type="submit" name="_edit" {% formaction %}="{% url 'dcim:powerfeed_bulk_edit' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-warning">
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %} <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.delete_cable %} {% if perms.dcim.delete_cable %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerfeed_bulk_disconnect' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-outline-danger"> <button type="submit" name="_disconnect" {% formaction %}="{% url 'dcim:powerfeed_bulk_disconnect' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %} <span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button> </button>
{% endif %} {% endif %}
{% if perms.dcim.delete_powerfeed %} {% if perms.dcim.delete_powerfeed %}
<button type="submit" name="_delete" formaction="{% url 'dcim:powerfeed_bulk_delete' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-danger"> <button type="submit" name="_delete" {% formaction %}="{% url 'dcim:powerfeed_bulk_delete' %}?return_url={% url 'dcim:powerpanel' pk=object.pk %}" class="btn btn-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %} <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -3,7 +3,7 @@
{% block bulk_buttons %} {% block bulk_buttons %}
{% if perms.extras.sync_configcontext %} {% if perms.extras.sync_configcontext %}
<button type="submit" name="_sync" formaction="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary"> <button type="submit" name="_sync" {% formaction %}="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %} <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -3,7 +3,7 @@
{% block bulk_buttons %} {% block bulk_buttons %}
{% if perms.extras.sync_configtemplate %} {% if perms.extras.sync_configtemplate %}
<button type="submit" name="_sync" formaction="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary"> <button type="submit" name="_sync" {% formaction %}="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %} <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
{% if htmx_url and has_permission %} {% if htmx_url and has_permission %}
<div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load"></div> <div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load" hx-target="this" hx-select="table" hx-swap="innerHTML"></div>
{% elif htmx_url %} {% elif htmx_url %}
<div class="text-muted text-center"> <div class="text-muted text-center">
<i class="mdi mdi-lock-outline"></i> {% trans "No permission to view this content" %}. <i class="mdi mdi-lock-outline"></i> {% trans "No permission to view this content" %}.

View File

@ -3,7 +3,7 @@
{% block bulk_buttons %} {% block bulk_buttons %}
{% if perms.extras.sync_configcontext %} {% if perms.extras.sync_configcontext %}
<button type="submit" name="_sync" formaction="{% url 'extras:exporttemplate_bulk_sync' %}" class="btn btn-primary"> <button type="submit" name="_sync" {% formaction %}="{% url 'extras:exporttemplate_bulk_sync' %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %} <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -21,7 +21,7 @@
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %} {% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit" <button type="submit" name="_edit"
formaction="{% url bulk_edit_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_edit_view %}?return_url={{ return_url }}"
class="btn btn-warning"> class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %} <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
</button> </button>
@ -35,7 +35,7 @@
{% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %} {% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}
{% if 'bulk_delete' in actions and bulk_delete_view %} {% if 'bulk_delete' in actions and bulk_delete_view %}
<button type="submit" <button type="submit"
formaction="{% url bulk_delete_view %}?return_url={{ return_url }}" {% formaction %}="{% url bulk_delete_view %}?return_url={{ return_url }}"
class="btn btn-danger"> class="btn btn-danger">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %} <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
</button> </button>

View File

@ -56,7 +56,7 @@ Context:
<form action="" method="post" enctype="multipart/form-data" class="object-edit mt-5"> <form action="" method="post" enctype="multipart/form-data" class="object-edit mt-5">
{% csrf_token %} {% csrf_token %}
<div id="form_fields"> <div id="form_fields" hx-disinherit="hx-select hx-swap">
{% block form %} {% block form %}
{% include 'htmx/form.html' %} {% include 'htmx/form.html' %}
{% endblock form %} {% endblock form %}

View File

@ -1,6 +1,6 @@
{% load helpers %} {% load helpers %}
<div id="django-messages" class="toast-container position-fixed bottom-0 end-0 p-3"> <div id="django-messages" class="toast-container position-fixed bottom-0 end-0 p-3" hx-swap-oob="true">
{# Non-Field Form Errors #} {# Non-Field Form Errors #}
{% if form and form.non_field_errors %} {% if form and form.non_field_errors %}

View File

@ -2,7 +2,12 @@
{% load i18n %} {% load i18n %}
{% if page %} {% if page %}
<div class="d-flex justify-content-between align-items-center border-top p-2"> <div
class="d-flex justify-content-between align-items-center border-top p-2"
hx-target="closest .htmx-container"
hx-disinherit="hx-select hx-swap"
{% if not table.embedded %}hx-push-url="true"{% endif %}
>
{# Pages carousel #} {# Pages carousel #}
{% if paginator.num_pages > 1 %} {% if paginator.num_pages > 1 %}
@ -13,12 +18,7 @@
{% if page.has_previous %} {% if page.has_previous %}
<li class="page-item"> <li class="page-item">
{% if htmx %} {% if htmx %}
<a href="#" <a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.previous_page_number %}" class="page-link">
hx-get="{{ table.htmx_url }}{% querystring request page=page.previous_page_number %}"
hx-target="closest .htmx-container"
{% if not table.embedded %}hx-push-url="true"{% endif %}
class="page-link"
>
<i class="mdi mdi-chevron-left"></i> <i class="mdi mdi-chevron-left"></i>
</a> </a>
{% else %} {% else %}
@ -34,12 +34,7 @@
{% for p in page.smart_pages %} {% for p in page.smart_pages %}
<li class="page-item{% if page.number == p %} active" aria-current="page{% endif %}"> <li class="page-item{% if page.number == p %} active" aria-current="page{% endif %}">
{% if p and htmx %} {% if p and htmx %}
<a href="#" <a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=p %}" class="page-link">
hx-get="{{ table.htmx_url }}{% querystring request page=p %}"
hx-target="closest .htmx-container"
{% if not table.embedded %}hx-push-url="true"{% endif %}
class="page-link"
>
{{ p }} {{ p }}
</a> </a>
{% elif p %} {% elif p %}
@ -57,12 +52,7 @@
{% if page.has_next %} {% if page.has_next %}
<li class="page-item"> <li class="page-item">
{% if htmx %} {% if htmx %}
<a href="#" <a href="#" hx-get="{{ table.htmx_url }}{% querystring request page=page.next_page_number %}" class="page-link">
hx-get="{{ table.htmx_url }}{% querystring request page=page.next_page_number %}"
hx-target="closest .htmx-container"
{% if not table.embedded %}hx-push-url="true"{% endif %}
class="page-link"
>
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
</a> </a>
{% else %} {% else %}
@ -97,12 +87,7 @@
<div class="dropdown-menu"> <div class="dropdown-menu">
{% for n in page.paginator.get_page_lengths %} {% for n in page.paginator.get_page_lengths %}
{% if htmx %} {% if htmx %}
<a href="#" <a href="#" hx-get="{{ table.htmx_url }}{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
hx-get="{{ table.htmx_url }}{% querystring request per_page=n %}"
hx-target="closest .htmx-container"
{% if not table.embedded %}hx-push-url="true"{% endif %}
class="dropdown-item"
>{{ n }}</a>
{% else %} {% else %}
<a href="{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a> <a href="{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
{% endif %} {% endif %}

View File

@ -3,7 +3,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-auto d-print-none"> <div class="col-auto d-print-none">
<div class="input-group input-group-flat me-2 quicksearch"> <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 px-2 py-1" placeholder="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">

View File

@ -1,7 +1,11 @@
{% load django_tables2 %} {% load django_tables2 %}
<table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}> <table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
{% if table.show_header %} {% if table.show_header %}
<thead> <thead
hx-target="closest .htmx-container"
hx-disinherit="hx-select hx-swap"
{% if not table.embedded %} hx-push-url="true"{% endif %}
>
<tr> <tr>
{% for column in table.columns %} {% for column in table.columns %}
{% if column.orderable %} {% if column.orderable %}
@ -10,16 +14,12 @@
<div class="float-end"> <div class="float-end">
<a href="#" <a href="#"
hx-get="{{ table.htmx_url }}{% querystring table.prefixed_order_by_field='' %}" hx-get="{{ table.htmx_url }}{% querystring table.prefixed_order_by_field='' %}"
hx-target="closest .htmx-container"
{% if not table.embedded %}hx-push-url="true"{% endif %}
class="text-danger" class="text-danger"
><i class="mdi mdi-close"></i></a> ><i class="mdi mdi-close"></i></a>
</div> </div>
{% endif %} {% endif %}
<a href="#" <a href="#"
hx-get="{{ table.htmx_url }}{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}" hx-get="{{ table.htmx_url }}{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}"
hx-target="closest .htmx-container"
{% if not table.embedded %}hx-push-url="true"{% endif %}
>{{ column.header }}</a> >{{ column.header }}</a>
</th> </th>
{% else %} {% else %}

View File

@ -1,41 +0,0 @@
{% load i18n %}
{% if request.user.is_authenticated %}
<div class="nav-item dropdown">
<a href="#" class="nav-link d-flex lh-1 text-reset p-0" data-bs-toggle="dropdown" aria-label="Open user menu">
<div class="d-xl-block ps-2">
<div>{{ request.user }}</div>
<div class="mt-1 small text-secondary">{% if request.user.is_staff %}Staff{% else %}User{% endif %}</div>
</div>
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow">
{% if config.DJANGO_ADMIN_ENABLED and request.user.is_staff %}
<a class="dropdown-item" href="{% url 'admin:index' %}">
<i class="mdi mdi-cog"></i> {% trans "Django Admin" %}
</a>
{% endif %}
<a href="{% url 'account:profile' %}" class="dropdown-item">
<i class="mdi mdi-account"></i> {% trans "Profile" %}
</a>
<a href="{% url 'account:bookmarks' %}" class="dropdown-item">
<i class="mdi mdi-bookmark"></i> {% trans "Bookmarks" %}
</a>
<a href="{% url 'account:preferences' %}" class="dropdown-item">
<i class="mdi mdi-wrench"></i> {% trans "Preferences" %}
</a>
<a href="{% url 'account:usertoken_list' %}" class="dropdown-item">
<i class="mdi mdi-key"></i> {% trans "API Tokens" %}
</a>
<div class="dropdown-divider"></div>
<a href="{% url 'logout' %}" class="dropdown-item">
<i class="mdi mdi-logout-variant"></i> {% trans "Log Out" %}
</a>
</div>
</div>
{% else %}
<div class="btn-group ps-2">
<a class="btn btn-primary" type="button" href="{% url 'login' %}?next={{ request.path }}">
<i class="mdi mdi-login-variant"></i> {% trans "Log In" %}
</a>
</div>
{% endif %}

View File

@ -5,7 +5,7 @@
{{ block.super }} {{ block.super }}
{% if 'bulk_remove_devices' in actions %} {% if 'bulk_remove_devices' in actions %}
<button type="submit" name="_remove" <button type="submit" name="_remove"
formaction="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}" {% formaction %}="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}"
class="btn btn-danger"> class="btn btn-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Selected" %} <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Selected" %}
</button> </button>

View File

@ -6,7 +6,7 @@
{{ block.super }} {{ block.super }}
{% if 'bulk_rename' in actions %} {% if 'bulk_rename' in actions %}
<button type="submit" name="_rename" <button type="submit" name="_rename"
formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}" {% formaction %}="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}"
class="btn btn-outline-warning"> class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %} <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button> </button>

View File

@ -6,7 +6,7 @@
{{ block.super }} {{ block.super }}
{% if 'bulk_rename' in actions %} {% if 'bulk_rename' in actions %}
<button type="submit" name="_rename" <button type="submit" name="_rename"
formaction="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}" {% formaction %}="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
class="btn btn-outline-warning"> class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %} <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button> </button>

View File

@ -10,14 +10,14 @@
<ul class="dropdown-menu"> <ul class="dropdown-menu">
{% if perms.virtualization.add_vminterface %} {% if perms.virtualization.add_vminterface %}
<li> <li>
<button type="submit" formaction="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %} {% trans "Interfaces" %}
</button> </button>
</li> </li>
{% endif %} {% endif %}
{% if perms.virtualization.add_virtualdisk %} {% if perms.virtualization.add_virtualdisk %}
<li> <li>
<button type="submit" formaction="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item"> <button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Virtual Disks" %} {% trans "Virtual Disks" %}
</button> </button>
</li> </li>

View File

@ -55,7 +55,8 @@ class UserConfigFormMetaclass(forms.models.ModelFormMetaclass):
class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass): class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
fieldsets = ( fieldsets = (
FieldSet( FieldSet(
'locale.language', 'pagination.per_page', 'pagination.placement', 'ui.colormode', name=_('User Interface') 'locale.language', 'pagination.per_page', 'pagination.placement', 'ui.colormode', 'ui.htmx_navigation',
name=_('User Interface')
), ),
FieldSet('data_format', name=_('Miscellaneous')), FieldSet('data_format', name=_('Miscellaneous')),
) )

13
netbox/utilities/htmx.py Normal file
View File

@ -0,0 +1,13 @@
__all__ = (
'htmx_partial',
)
PAGE_CONTAINER_ID = 'page-content'
def htmx_partial(request):
"""
Determines whether to render partial (versus complete) HTML content
in response to an HTMX request, based on the target element.
"""
return request.htmx and request.htmx.target != PAGE_CONTAINER_ID

View File

@ -1,4 +1,5 @@
<div class="card-body htmx-container table-responsive p-0" <div class="card-body htmx-container table-responsive p-0"
hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}" hx-get="{% url viewname %}{% if url_params %}?{{ url_params.urlencode }}{% endif %}"
hx-trigger="load" hx-target="this"
hx-trigger="load" hx-select="table" hx-swap="innerHTML"
></div> ></div>

View File

@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
{% if url %} {% if url %}
<button type="submit" name="_delete" formaction="{{ url }}" class="btn btn-red"> <button type="submit" name="_delete" {% formaction %}="{{ url }}" class="btn btn-red">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %} <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -1,6 +1,6 @@
{% load i18n %} {% load i18n %}
{% if url %} {% if url %}
<button type="submit" name="_edit" formaction="{{ url }}" class="btn btn-yellow"> <button type="submit" name="_edit" {% formaction %}="{{ url }}" class="btn btn-yellow">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %} <i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
</button> </button>
{% endif %} {% endif %}

View File

@ -2,6 +2,8 @@
<a href="#" <a href="#"
hx-get="{{ url }}" hx-get="{{ url }}"
hx-target="#htmx-modal-content" hx-target="#htmx-modal-content"
hx-swap="innerHTML"
hx-select="form"
class="btn btn-red" class="btn btn-red"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#htmx-modal" data-bs-target="#htmx-modal"

View File

@ -1,6 +1,7 @@
{% load helpers %} {% load helpers %}
{% load navigation %}
<ul class="navbar-nav pt-lg-2"> <ul class="navbar-nav pt-lg-2" {% htmx_boost %}>
{% for menu, groups in nav_items %} {% for menu, groups in nav_items %}
<li class="nav-item dropdown"> <li class="nav-item dropdown">

View File

@ -8,6 +8,7 @@ __all__ = (
'checkmark', 'checkmark',
'copy_content', 'copy_content',
'customfield_value', 'customfield_value',
'formaction',
'tag', 'tag',
) )
@ -113,3 +114,14 @@ def htmx_table(context, viewname, return_url=None, **kwargs):
'viewname': viewname, 'viewname': viewname,
'url_params': url_params, 'url_params': url_params,
} }
@register.simple_tag(takes_context=True)
def formaction(context):
"""
Replace the 'formaction' attribute on an HTML element with the appropriate HTMX attributes
if HTMX navigation is enabled (per the user's preferences).
"""
if context.get('htmx_navigation', False):
return 'hx-push-url="true" hx-post'
return 'formaction'

View File

@ -1,11 +1,11 @@
from typing import Dict
from django import template from django import template
from django.template import Context from django.utils.safestring import mark_safe
from netbox.navigation.menu import MENUS from netbox.navigation.menu import MENUS
__all__ = ( __all__ = (
'nav', 'nav',
'htmx_boost',
) )
@ -13,7 +13,7 @@ register = template.Library()
@register.inclusion_tag("navigation/menu.html", takes_context=True) @register.inclusion_tag("navigation/menu.html", takes_context=True)
def nav(context: Context) -> Dict: def nav(context):
""" """
Render the navigation menu. Render the navigation menu.
""" """
@ -40,6 +40,31 @@ def nav(context: Context) -> Dict:
nav_items.append((menu, groups)) nav_items.append((menu, groups))
return { return {
"nav_items": nav_items, 'nav_items': nav_items,
"request": context["request"] 'htmx_navigation': context['htmx_navigation']
} }
@register.simple_tag(takes_context=True)
def htmx_boost(context, target='#page-content', select='#page-content'):
"""
Renders the HTML attributes needed to effect HTMX boosting within an element if
HTMX navigation is enabled for the request. The target and select parameters are
rendered as `hx-target` and `hx-select`, respectively. For example:
<div id="page-content" {% htmx_boost %}>
If HTMX navigation is not enabled, the tag renders no content.
"""
if not context.get('htmx_navigation', False):
return ''
hx_params = {
'hx-boost': 'true',
'hx-target': target,
'hx-select': select,
'hx-swap': 'outerHTML show:window:top',
}
htmx_params = ' '.join([
f'{k}="{v}"' for k, v in hx_params.items()
])
return mark_safe(htmx_params)

View File

@ -1,3 +1,5 @@
import decimal
from django.contrib.contenttypes.fields import GenericRelation from django.contrib.contenttypes.fields import GenericRelation
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@ -112,7 +114,7 @@ class VirtualMachine(ContactsMixin, RenderConfigMixin, ConfigContextModel, Prima
null=True, null=True,
verbose_name=_('vCPUs'), verbose_name=_('vCPUs'),
validators=( validators=(
MinValueValidator(0.01), MinValueValidator(decimal.Decimal(0.01)),
) )
) )
memory = models.PositiveIntegerField( memory = models.PositiveIntegerField(

View File

@ -47,7 +47,8 @@ class TunnelSerializer(NetBoxModelSerializer):
group = TunnelGroupSerializer( group = TunnelGroupSerializer(
nested=True, nested=True,
required=False, required=False,
allow_null=True allow_null=True,
default=None
) )
encapsulation = ChoiceField( encapsulation = ChoiceField(
choices=TunnelEncapsulationChoices choices=TunnelEncapsulationChoices

View File

@ -1,28 +1,28 @@
Django==5.0.3 Django==5.0.3
django-cors-headers==4.3.1 django-cors-headers==4.3.1
django-debug-toolbar==4.3.0 django-debug-toolbar==4.3.0
django-filter==24.1 django-filter==24.2
django-htmx==1.17.3 django-htmx==1.17.3
django-mptt==0.14.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.8.0
django-rq==2.10.1 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.14.0 djangorestframework==3.15.1
drf-spectacular==0.27.1 drf-spectacular==0.27.1
drf-spectacular-sidecar==2024.3.4 drf-spectacular-sidecar==2024.3.4
feedparser==6.0.11 feedparser==6.0.11
gunicorn==21.2.0 gunicorn==21.2.0
Jinja2==3.1.3 Jinja2==3.1.3
Markdown==3.5.2 Markdown==3.6
mkdocs-material==9.5.13 mkdocs-material==9.5.15
mkdocstrings[python-legacy]==0.24.1 mkdocstrings[python-legacy]==0.24.1
netaddr==1.2.1 netaddr==1.2.1
nh3==0.2.15 nh3==0.2.17
Pillow==10.2.0 Pillow==10.2.0
psycopg[c,pool]==3.1.18 psycopg[c,pool]==3.1.18
PyYAML==6.0.1 PyYAML==6.0.1
@ -32,5 +32,5 @@ social-auth-core[openidconnect]==4.5.3
strawberry-graphql==0.221.1 strawberry-graphql==0.221.1
strawberry-graphql-django==0.34.0 strawberry-graphql-django==0.34.0
svgwrite==1.4.3 svgwrite==1.4.3
tablib==3.5.0 tablib==3.6.0
tzdata==2024.1 tzdata==2024.1