Merge branch 'feature' into 13149-form-gettext

This commit is contained in:
Jeremy Stretch 2023-07-31 11:22:56 -04:00
commit 406cee91cc
325 changed files with 124714 additions and 2434 deletions

View File

@ -68,8 +68,13 @@ When defining a permission constraint, administrators may use the special token
The `$user` token can be used only as a constraint value, or as an item within a list of values. It cannot be modified or extended to reference specific user attributes.
### Default Permissions
#### Example Constraint Definitions
!!! info "This feature was introduced in NetBox v3.6."
While permissions are typically assigned to specific groups and/or users, it is also possible to define a set of default permissions that are applied to _all_ authenticated users. This is done using the [`DEFAULT_PERMISSIONS`](../configuration/security.md#default_permissions) configuration parameter. Note that statically configuring permissions for specific users or groups is **not** supported.
### Example Constraint Definitions
| Constraints | Description |
| ----------- | ----------- |

View File

@ -90,6 +90,38 @@ CSRF_TRUSTED_ORIGINS = (
---
## DEFAULT_PERMISSIONS
!!! info "This parameter was introduced in NetBox v3.6."
Default:
```python
{
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
'users.change_token': ({'user': '$user'},),
'users.delete_token': ({'user': '$user'},),
}
```
This parameter defines object permissions that are applied automatically to _any_ authenticated user, regardless of what permissions have been defined in the database. By default, this parameter is defined to allow all users to manage their own API tokens, however it can be overriden for any purpose.
For example, to allow all users to create a device role beginning with the word "temp," you could configure the following:
```python
DEFAULT_PERMISSIONS = {
'dcim.add_devicerole': (
{'name__startswith': 'temp'},
)
}
```
!!! warning
Setting a custom value for this parameter will overwrite the default permission mapping shown above. If you want to retain the default mapping, be sure to reproduce it in your custom configuration.
---
## EXEMPT_VIEW_PERMISSIONS
Default: Empty list

View File

@ -1,6 +1,8 @@
# Custom Field Choice Sets
Single- and multi-selection [custom fields documentation](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
Single- and multi-selection [custom fields](../../customization/custom-fields.md) must define a set of valid choices from which the user may choose when defining the field value. These choices are defined as sets that may be reused among multiple custom fields.
A choice set must define a base choice set and/or a set of arbitrary extra choices.
## Fields
@ -8,9 +10,17 @@ Single- and multi-selection [custom fields documentation](../../customization/cu
The human-friendly name of the choice set.
### Base Choices
The set of pre-defined choices to include. Available sets are listed below. This is an optional setting.
* IATA airport codes
* ISO 3166 - Two-letter country codes
* UN/LOCODE - Five-character location identifiers
### Extra Choices
The list of valid choices, entered as a comma-separated list.
A set of custom choices that will be appended to the base choice set (if any).
### Order Alphabetically

View File

@ -21,13 +21,27 @@ Management views for the following object types, previously available only under
The admin UI is scheduled for removal in NetBox v4.0.
#### Configurable Default Permissions ([#13038](https://github.com/netbox-community/netbox/issues/13038))
Administrators now have the option of configuring a set of default permissions to be applied to _all_ users, regardless of explicit permission or group assignment. This is accomplished by defining the `DEFAULT_PERMISSIONS` configuration parameter.
#### User Bookmarks ([#8248](https://github.com/netbox-community/netbox/issues/8248))
Users can now bookmark their most commonly-visited objects in NetBox. Bookmarks will display both on the dashboard (if configured) and on a user-specific bookmarks view.
#### Custom Field Choice Sets ([#12988](https://github.com/netbox-community/netbox/issues/12988))
Select and multi-select custom fields now employ discrete, reusable choice sets containing the valid options for each field. A choice set may be shared by multiple custom fields.
Select and multi-select custom fields now employ discrete, reusable choice sets containing the valid options for each field. A choice set may be shared by multiple custom fields. Additionally, each choice within a set can now specify both a value and a human-friendly label (see [#13241](https://github.com/netbox-community/netbox/issues/13241)).
#### Pre-Defined Location Choices for Custom Fields ([#12194](https://github.com/netbox-community/netbox/issues/12194))
Users now have the option to employ one of several pre-defined choice sets when creating a custom field. These include:
* IATA airport codes
* ISO 3166 country codes
* UN/LOCODE identifiers
Extra choices may also be appended to the pre-defined sets as needed.
#### Restrict Tag Usage by Object Type ([#11541](https://github.com/netbox-community/netbox/issues/11541))
@ -40,10 +54,13 @@ Tags may now be restricted to use with designated object types. Tags that have n
* [#10197](https://github.com/netbox-community/netbox/issues/10197) - Cache the number of member devices on each virtual chassis
* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model
* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one
* [#12210](https://github.com/netbox-community/netbox/issues/12210) - Add tenancy assignment for power feeds
* [#13170](https://github.com/netbox-community/netbox/issues/13170) - Add `rf_role` to InterfaceTemplate
* [#13269](https://github.com/netbox-community/netbox/issues/13269) - Cache the number of assigned component templates for device types
### Other Changes
* Work has begun on introducing translation and localization support in NetBox. This work is being performed in preparation for release 4.0.
* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates
* [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes
* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view

View File

View File

@ -1,10 +1,12 @@
# Generated by Django 4.1.10 on 2023-07-25 15:19
# Generated by Django 4.1.10 on 2023-07-30 17:49
from django.db import migrations
class Migration(migrations.Migration):
initial = True
dependencies = [
('users', '0004_netboxgroup_netboxuser'),
]
@ -15,10 +17,10 @@ class Migration(migrations.Migration):
fields=[
],
options={
'verbose_name': 'token',
'proxy': True,
'indexes': [],
'constraints': [],
'verbose_name': 'token',
},
bases=('users.token',),
),

View File

15
netbox/account/models.py Normal file
View File

@ -0,0 +1,15 @@
from django.urls import reverse
from users.models import Token
class UserToken(Token):
"""
Proxy model for users to manage their own API tokens.
"""
class Meta:
proxy = True
verbose_name = 'token'
def get_absolute_url(self):
return reverse('account:usertoken', args=[self.pk])

55
netbox/account/tables.py Normal file
View File

@ -0,0 +1,55 @@
from django.utils.translation import gettext as _
from account.models import UserToken
from netbox.tables import NetBoxTable, columns
__all__ = (
'UserTokenTable',
)
TOKEN = """<samp><span id="token_{{ record.pk }}">{{ record }}</span></samp>"""
ALLOWED_IPS = """{{ value|join:", " }}"""
COPY_BUTTON = """
{% if settings.ALLOW_TOKEN_RETRIEVAL %}
{% copy_content record.pk prefix="token_" color="success" %}
{% endif %}
"""
class UserTokenTable(NetBoxTable):
"""
Table for users to manager their own API tokens under account views.
"""
key = columns.TemplateColumn(
verbose_name=_('Key'),
template_code=TOKEN,
)
write_enabled = columns.BooleanColumn(
verbose_name=_('Write Enabled')
)
created = columns.DateColumn(
verbose_name=_('Created'),
)
expires = columns.DateColumn(
verbose_name=_('Expires'),
)
last_used = columns.DateTimeColumn(
verbose_name=_('Last Used'),
)
allowed_ips = columns.TemplateColumn(
verbose_name=_('Allowed IPs'),
template_code=ALLOWED_IPS
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
extra_buttons=COPY_BUTTON
)
class Meta(NetBoxTable.Meta):
model = UserToken
fields = (
'pk', 'id', 'key', 'description', 'write_enabled', 'created', 'expires', 'last_used', 'allowed_ips',
)

View File

@ -13,6 +13,6 @@ urlpatterns = [
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),
path('api-tokens/add/', views.UserTokenEditView.as_view(), name='usertoken_add'),
path('api-tokens/<int:pk>/', include(get_model_urls('users', 'usertoken'))),
path('api-tokens/<int:pk>/', include(get_model_urls('account', 'usertoken'))),
]

298
netbox/account/views.py Normal file
View File

@ -0,0 +1,298 @@
import logging
from django.conf import settings
from django.contrib import messages
from django.contrib.auth import login as auth_login, logout as auth_logout
from django.contrib.auth import update_session_auth_hash
from django.contrib.auth.mixins import LoginRequiredMixin
from django.contrib.auth.models import update_last_login
from django.contrib.auth.signals import user_logged_in
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, redirect
from django.shortcuts import render, resolve_url
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.http import url_has_allowed_host_and_scheme, urlencode
from django.views.decorators.debug import sensitive_post_parameters
from django.views.generic import View
from social_core.backends.utils import load_backends
from account.models import UserToken
from extras.models import Bookmark, ObjectChange
from extras.tables import BookmarkTable, ObjectChangeTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
from users import forms, tables
from users.models import UserConfig
from utilities.views import register_model_view
#
# Login/logout
#
class LoginView(View):
"""
Perform user authentication via the web UI.
"""
template_name = 'login.html'
@method_decorator(sensitive_post_parameters('password'))
def dispatch(self, *args, **kwargs):
return super().dispatch(*args, **kwargs)
def gen_auth_data(self, name, url, params):
display_name, icon_name = get_auth_backend_display(name)
return {
'display_name': display_name,
'icon_name': icon_name,
'url': f'{url}?{urlencode(params)}',
}
def get_auth_backends(self, request):
auth_backends = []
saml_idps = get_saml_idps()
for name in load_backends(settings.AUTHENTICATION_BACKENDS).keys():
url = reverse('social:begin', args=[name])
params = {}
if next := request.GET.get('next'):
params['next'] = next
if name.lower() == 'saml' and saml_idps:
for idp in saml_idps:
params['idp'] = idp
data = self.gen_auth_data(name, url, params)
data['display_name'] = f'{data["display_name"]} ({idp})'
auth_backends.append(data)
else:
auth_backends.append(self.gen_auth_data(name, url, params))
return auth_backends
def get(self, request):
form = forms.LoginForm(request)
if request.user.is_authenticated:
logger = logging.getLogger('netbox.auth.login')
return self.redirect_to_next(request, logger)
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
})
def post(self, request):
logger = logging.getLogger('netbox.auth.login')
form = forms.LoginForm(request, data=request.POST)
if form.is_valid():
logger.debug("Login form validation was successful")
# If maintenance mode is enabled, assume the database is read-only, and disable updating the user's
# last_login time upon authentication.
if get_config().MAINTENANCE_MODE:
logger.warning("Maintenance mode enabled: disabling update of most recent login time")
user_logged_in.disconnect(update_last_login, dispatch_uid='update_last_login')
# Authenticate user
auth_login(request, form.get_user())
logger.info(f"User {request.user} successfully authenticated")
messages.success(request, f"Logged in as {request.user}.")
# Ensure the user has a UserConfig defined. (This should normally be handled by
# create_userconfig() on user creation.)
if not hasattr(request.user, 'config'):
config = get_config()
UserConfig(user=request.user, data=config.DEFAULT_USER_PREFERENCES).save()
return self.redirect_to_next(request, logger)
else:
logger.debug(f"Login form validation failed for username: {form['username'].value()}")
return render(request, self.template_name, {
'form': form,
'auth_backends': self.get_auth_backends(request),
})
def redirect_to_next(self, request, logger):
data = request.POST if request.method == "POST" else request.GET
redirect_url = data.get('next', settings.LOGIN_REDIRECT_URL)
if redirect_url and url_has_allowed_host_and_scheme(redirect_url, allowed_hosts=None):
logger.debug(f"Redirecting user to {redirect_url}")
else:
if redirect_url:
logger.warning(f"Ignoring unsafe 'next' URL passed to login form: {redirect_url}")
redirect_url = reverse('home')
return HttpResponseRedirect(redirect_url)
class LogoutView(View):
"""
Deauthenticate a web user.
"""
def get(self, request):
logger = logging.getLogger('netbox.auth.logout')
# Log out the user
username = request.user
auth_logout(request)
logger.info(f"User {username} has logged out")
messages.info(request, "You have logged out.")
# Delete session key cookie (if set) upon logout
response = HttpResponseRedirect(resolve_url(settings.LOGOUT_REDIRECT_URL))
response.delete_cookie('session_key')
return response
#
# User profiles
#
class ProfileView(LoginRequiredMixin, View):
template_name = 'account/profile.html'
def get(self, request):
# Compile changelog table
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(
user=request.user
).prefetch_related(
'changed_object_type'
)[:20]
changelog_table = ObjectChangeTable(changelog)
return render(request, self.template_name, {
'changelog_table': changelog_table,
'active_tab': 'profile',
})
class UserConfigView(LoginRequiredMixin, View):
template_name = 'account/preferences.html'
def get(self, request):
userconfig = request.user.config
form = forms.UserConfigForm(instance=userconfig)
return render(request, self.template_name, {
'form': form,
'active_tab': 'preferences',
})
def post(self, request):
userconfig = request.user.config
form = forms.UserConfigForm(request.POST, instance=userconfig)
if form.is_valid():
form.save()
messages.success(request, "Your preferences have been updated.")
return redirect('account:preferences')
return render(request, self.template_name, {
'form': form,
'active_tab': 'preferences',
})
class ChangePasswordView(LoginRequiredMixin, View):
template_name = 'account/password.html'
def get(self, request):
# LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('account:profile')
form = forms.PasswordChangeForm(user=request.user)
return render(request, self.template_name, {
'form': form,
'active_tab': 'password',
})
def post(self, request):
form = forms.PasswordChangeForm(user=request.user, data=request.POST)
if form.is_valid():
form.save()
update_session_auth_hash(request, form.user)
messages.success(request, "Your password has been changed successfully.")
return redirect('account:profile')
return render(request, self.template_name, {
'form': form,
'active_tab': 'change_password',
})
#
# Bookmarks
#
class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
table = BookmarkTable
template_name = 'account/bookmarks.html'
def get_queryset(self, request):
return Bookmark.objects.filter(user=request.user)
def get_extra_context(self, request):
return {
'active_tab': 'bookmarks',
}
#
# User views for token management
#
class UserTokenListView(LoginRequiredMixin, View):
def get(self, request):
tokens = UserToken.objects.filter(user=request.user)
table = tables.UserTokenTable(tokens)
table.configure(request)
return render(request, 'account/token_list.html', {
'tokens': tokens,
'active_tab': 'api-tokens',
'table': table,
})
@register_model_view(UserToken)
class UserTokenView(LoginRequiredMixin, View):
def get(self, request, pk):
token = get_object_or_404(UserToken.objects.filter(user=request.user), pk=pk)
key = token.key if settings.ALLOW_TOKEN_RETRIEVAL else None
return render(request, 'account/token.html', {
'object': token,
'key': key,
})
@register_model_view(UserToken, 'edit')
class UserTokenEditView(generic.ObjectEditView):
queryset = UserToken.objects.all()
form = forms.UserTokenForm
default_return_url = 'account:usertoken_list'
def alter_object(self, obj, request, url_args, url_kwargs):
if not obj.pk:
obj.user = request.user
return obj
@register_model_view(UserToken, 'delete')
class UserTokenDeleteView(generic.ObjectDeleteView):
queryset = UserToken.objects.all()
default_return_url = 'account:usertoken_list'

View File

@ -1236,6 +1236,10 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
choices=PowerFeedPhaseChoices,
default=lambda: PowerFeedPhaseChoices.PHASE_SINGLE,
)
tenant = NestedTenantSerializer(
required=False,
allow_null=True
)
class Meta:
model = PowerFeed
@ -1243,5 +1247,5 @@ class PowerFeedSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
'amperage', 'max_utilization', 'mark_connected', 'cable', 'cable_end', 'link_peers', 'link_peers_type',
'connected_endpoints', 'connected_endpoints_type', 'connected_endpoints_reachable', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
'tenant', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]

View File

@ -6,7 +6,6 @@ from netaddr import AddrFormatError, EUI, eui64_unix_expanded, mac_unix_expanded
from .lookups import PathContains
__all__ = (
'ASNField',
'MACAddressField',
'PathField',
'WWNField',

View File

@ -1880,7 +1880,7 @@ class PowerPanelFilterSet(NetBoxModelFilterSet, ContactModelFilterSet):
return queryset.filter(qs_filter)
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet):
class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpointFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
field_name='power_panel__site__region',

View File

@ -854,6 +854,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
required=False,
widget=BulkEditNullBooleanSelect
)
tenant = DynamicModelChoiceField(
queryset=Tenant.objects.all(),
required=False
)
description = forms.CharField(
label=_('Description'),
max_length=200,
@ -865,10 +869,10 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm):
model = PowerFeed
fieldsets = (
(None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description')),
(None, ('power_panel', 'rack', 'status', 'type', 'mark_connected', 'description', 'tenant')),
(_('Power'), ('supply', 'phase', 'voltage', 'amperage', 'max_utilization'))
)
nullable_fields = ('location', 'description', 'comments')
nullable_fields = ('location', 'tenant', 'description', 'comments')
#

View File

@ -1291,6 +1291,12 @@ class PowerFeedImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Rack')
)
tenant = CSVModelChoiceField(
queryset=Tenant.objects.all(),
to_field_name='name',
required=False,
help_text=_('Assigned tenant')
)
status = CSVChoiceField(
label=_('Status'),
choices=PowerFeedStatusChoices,
@ -1316,7 +1322,7 @@ class PowerFeedImportForm(NetBoxModelImportForm):
model = PowerFeed
fields = (
'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase',
'voltage', 'amperage', 'max_utilization', 'description', 'comments', 'tags',
'voltage', 'amperage', 'max_utilization', 'tenant', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):

View File

@ -1020,11 +1020,12 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class PowerFeedFilterForm(NetBoxModelFilterSetForm):
class PowerFeedFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
model = PowerFeed
fieldsets = (
(None, ('q', 'filter_id', 'tag')),
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'power_panel_id', 'rack_id')),
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
(_('Attributes'), ('status', 'type', 'supply', 'phase', 'voltage', 'amperage', 'max_utilization')),
)
region_id = DynamicModelMultipleChoiceField(

View File

@ -675,7 +675,7 @@ class PowerPanelForm(NetBoxModelForm):
]
class PowerFeedForm(NetBoxModelForm):
class PowerFeedForm(TenancyForm, NetBoxModelForm):
power_panel = DynamicModelChoiceField(
label=_('Power panel'),
queryset=PowerPanel.objects.all(),
@ -694,13 +694,14 @@ class PowerFeedForm(NetBoxModelForm):
fieldsets = (
(_('Power Feed'), ('power_panel', 'rack', 'name', 'status', 'type', 'description', 'mark_connected', 'tags')),
(_('Characteristics'), ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
(_('Tenancy'), ('tenant_group', 'tenant')),
)
class Meta:
model = PowerFeed
fields = [
'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization', 'description', 'comments', 'tags',
'max_utilization', 'tenant_group', 'tenant', 'description', 'comments', 'tags'
]

View File

@ -53,23 +53,23 @@ class LinkPeerType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination:
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) == ConsolePortType:
if type(instance) is ConsolePortType:
return ConsolePortType
if type(instance) == ConsoleServerPort:
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
if type(instance) is FrontPort:
return FrontPortType
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == PowerFeed:
if type(instance) is PowerFeed:
return PowerFeedType
if type(instance) == PowerOutlet:
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
if type(instance) is PowerPort:
return PowerPortType
if type(instance) == RearPort:
if type(instance) is RearPort:
return RearPortType
@ -89,23 +89,23 @@ class CableTerminationTerminationType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == CircuitTermination:
if type(instance) is CircuitTermination:
return CircuitTerminationType
if type(instance) == ConsolePortType:
if type(instance) is ConsolePortType:
return ConsolePortType
if type(instance) == ConsoleServerPort:
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
if type(instance) is FrontPort:
return FrontPortType
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == PowerFeed:
if type(instance) is PowerFeed:
return PowerFeedType
if type(instance) == PowerOutlet:
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
if type(instance) is PowerPort:
return PowerPortType
if type(instance) == RearPort:
if type(instance) is RearPort:
return RearPortType
@ -123,19 +123,19 @@ class InventoryItemTemplateComponentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == ConsolePortTemplate:
if type(instance) is ConsolePortTemplate:
return ConsolePortTemplateType
if type(instance) == ConsoleServerPortTemplate:
if type(instance) is ConsoleServerPortTemplate:
return ConsoleServerPortTemplateType
if type(instance) == FrontPortTemplate:
if type(instance) is FrontPortTemplate:
return FrontPortTemplateType
if type(instance) == InterfaceTemplate:
if type(instance) is InterfaceTemplate:
return InterfaceTemplateType
if type(instance) == PowerOutletTemplate:
if type(instance) is PowerOutletTemplate:
return PowerOutletTemplateType
if type(instance) == PowerPortTemplate:
if type(instance) is PowerPortTemplate:
return PowerPortTemplateType
if type(instance) == RearPortTemplate:
if type(instance) is RearPortTemplate:
return RearPortTemplateType
@ -153,17 +153,17 @@ class InventoryItemComponentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == ConsolePort:
if type(instance) is ConsolePort:
return ConsolePortType
if type(instance) == ConsoleServerPort:
if type(instance) is ConsoleServerPort:
return ConsoleServerPortType
if type(instance) == FrontPort:
if type(instance) is FrontPort:
return FrontPortType
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == PowerOutlet:
if type(instance) is PowerOutlet:
return PowerOutletType
if type(instance) == PowerPort:
if type(instance) is PowerPort:
return PowerPortType
if type(instance) == RearPort:
if type(instance) is RearPort:
return RearPortType

View File

@ -0,0 +1,20 @@
# Generated by Django 4.1.8 on 2023-07-29 11:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0010_tenant_relax_uniqueness'),
('dcim', '0179_interfacetemplate_rf_role'),
]
operations = [
migrations.AddField(
model_name='powerfeed',
name='tenant',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='power_feeds', to='tenancy.tenant'),
),
]

View File

@ -131,10 +131,17 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
default=0,
editable=False
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
on_delete=models.PROTECT,
related_name='power_feeds',
blank=True,
null=True
)
clone_fields = (
'power_panel', 'rack', 'status', 'type', 'mark_connected', 'supply', 'phase', 'voltage', 'amperage',
'max_utilization',
'max_utilization', 'tenant',
)
prerequisite_models = (
'dcim.PowerPanel',

View File

@ -1,6 +1,6 @@
import django_tables2 as tables
from dcim.models import PowerFeed, PowerPanel
from tenancy.tables import ContactsColumnMixin
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from netbox.tables import NetBoxTable, columns
@ -51,7 +51,7 @@ class PowerPanelTable(ContactsColumnMixin, NetBoxTable):
# We're not using PathEndpointTable for PowerFeed because power connections
# cannot traverse pass-through ports.
class PowerFeedTable(CableTerminationTable):
class PowerFeedTable(TenancyColumnsMixin, CableTerminationTable):
name = tables.Column(
linkify=True
)
@ -69,6 +69,9 @@ class PowerFeedTable(CableTerminationTable):
available_power = tables.Column(
verbose_name='Available power (VA)'
)
tenant = tables.Column(
linkify=True
)
comments = columns.MarkdownColumn()
tags = columns.TagColumn(
url_name='dcim:powerfeed_list'
@ -78,8 +81,8 @@ class PowerFeedTable(CableTerminationTable):
model = PowerFeed
fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power',
'description', 'comments', 'tags', 'created', 'last_updated',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'available_power', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -4419,6 +4419,21 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Rack.objects.bulk_create(racks)
tenant_groups = (
TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
)
for tenantgroup in tenant_groups:
tenantgroup.save()
tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
)
Tenant.objects.bulk_create(tenants)
power_panels = (
PowerPanel(name='Power Panel 1', site=sites[0]),
PowerPanel(name='Power Panel 2', site=sites[1]),
@ -4427,9 +4442,44 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
PowerPanel.objects.bulk_create(power_panels)
power_feeds = (
PowerFeed(power_panel=power_panels[0], rack=racks[0], name='Power Feed 1', status=PowerFeedStatusChoices.STATUS_ACTIVE, type=PowerFeedTypeChoices.TYPE_PRIMARY, supply=PowerFeedSupplyChoices.SUPPLY_AC, phase=PowerFeedPhaseChoices.PHASE_3PHASE, voltage=100, amperage=100, max_utilization=10),
PowerFeed(power_panel=power_panels[1], rack=racks[1], name='Power Feed 2', status=PowerFeedStatusChoices.STATUS_FAILED, type=PowerFeedTypeChoices.TYPE_PRIMARY, supply=PowerFeedSupplyChoices.SUPPLY_AC, phase=PowerFeedPhaseChoices.PHASE_3PHASE, voltage=200, amperage=200, max_utilization=20),
PowerFeed(power_panel=power_panels[2], rack=racks[2], name='Power Feed 3', status=PowerFeedStatusChoices.STATUS_OFFLINE, type=PowerFeedTypeChoices.TYPE_REDUNDANT, supply=PowerFeedSupplyChoices.SUPPLY_DC, phase=PowerFeedPhaseChoices.PHASE_SINGLE, voltage=300, amperage=300, max_utilization=30),
PowerFeed(
power_panel=power_panels[0],
rack=racks[0],
name='Power Feed 1',
tenant=tenants[0],
status=PowerFeedStatusChoices.STATUS_ACTIVE,
type=PowerFeedTypeChoices.TYPE_PRIMARY,
supply=PowerFeedSupplyChoices.SUPPLY_AC,
phase=PowerFeedPhaseChoices.PHASE_3PHASE,
voltage=100,
amperage=100,
max_utilization=10
),
PowerFeed(
power_panel=power_panels[1],
rack=racks[1],
name='Power Feed 2',
tenant=tenants[1],
status=PowerFeedStatusChoices.STATUS_FAILED,
type=PowerFeedTypeChoices.TYPE_PRIMARY,
supply=PowerFeedSupplyChoices.SUPPLY_AC,
phase=PowerFeedPhaseChoices.PHASE_3PHASE,
voltage=200,
amperage=200,
max_utilization=20),
PowerFeed(
power_panel=power_panels[2],
rack=racks[2],
name='Power Feed 3',
tenant=tenants[2],
status=PowerFeedStatusChoices.STATUS_OFFLINE,
type=PowerFeedTypeChoices.TYPE_REDUNDANT,
supply=PowerFeedSupplyChoices.SUPPLY_DC,
phase=PowerFeedPhaseChoices.PHASE_SINGLE,
voltage=300,
amperage=300,
max_utilization=30
),
)
PowerFeed.objects.bulk_create(power_feeds)
@ -4520,6 +4570,20 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_tenant(self):
tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VirtualDeviceContextTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VirtualDeviceContext.objects.all()

View File

@ -131,12 +131,16 @@ class CustomFieldSerializer(ValidatedModelSerializer):
class CustomFieldChoiceSetSerializer(ValidatedModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:customfieldchoiceset-detail')
base_choices = ChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False
)
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'url', 'display', 'name', 'description', 'extra_choices', 'order_alphabetically', 'choices_count',
'created', 'last_updated',
'id', 'url', 'display', 'name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically',
'choices_count', 'created', 'last_updated',
]

View File

@ -1,5 +1,6 @@
from django.contrib.contenttypes.models import ContentType
from django.http import Http404
from django.shortcuts import get_object_or_404
from django_rq.queues import get_connection
from rest_framework import status
from rest_framework.decorators import action
@ -63,6 +64,26 @@ class CustomFieldChoiceSetViewSet(NetBoxModelViewSet):
serializer_class = serializers.CustomFieldChoiceSetSerializer
filterset_class = filtersets.CustomFieldChoiceSetFilterSet
@action(detail=True)
def choices(self, request, pk):
"""
Provides an endpoint to iterate through each choice in a set.
"""
choiceset = get_object_or_404(self.queryset, pk=pk)
choices = choiceset.choices
# Enable filtering
if q := request.GET.get('q'):
q = q.lower()
choices = [c for c in choices if q in c[0].lower() or q in c[1].lower()]
# Paginate data
if page := self.paginate_queryset(choices):
data = [
{'value': c[0], 'label': c[1]} for c in page
]
return self.get_paginated_response(data)
#
# Custom links

View File

@ -66,6 +66,19 @@ class CustomFieldVisibilityChoices(ChoiceSet):
)
class CustomFieldChoiceSetBaseChoices(ChoiceSet):
IATA = 'IATA'
ISO_3166 = 'ISO_3166'
UN_LOCODE = 'UN_LOCODE'
CHOICES = (
(IATA, 'IATA (Airport codes)'),
(ISO_3166, 'ISO 3166 (Country codes)'),
(UN_LOCODE, 'UN/LOCODE (Location codes)'),
)
#
# CustomLinks
#

View File

@ -0,0 +1,9 @@
from .iata import IATA
from .iso_3166 import ISO_3166
from .un_locode import UN_LOCODE
CHOICE_SETS = {
'IATA': IATA,
'ISO_3166': ISO_3166,
'UN_LOCODE': UN_LOCODE,
}

9768
netbox/extras/data/iata.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,253 @@
# Two-letter country codes defined by ISO 3166
# Source: https://datahub.io/core/country-list
ISO_3166 = [
('AD', 'AD (Andorra)'),
('AE', 'AE (United Arab Emirates)'),
('AF', 'AF (Afghanistan)'),
('AG', 'AG (Antigua and Barbuda)'),
('AI', 'AI (Anguilla)'),
('AL', 'AL (Albania)'),
('AM', 'AM (Armenia)'),
('AO', 'AO (Angola)'),
('AQ', 'AQ (Antarctica)'),
('AR', 'AR (Argentina)'),
('AS', 'AS (American Samoa)'),
('AT', 'AT (Austria)'),
('AU', 'AU (Australia)'),
('AW', 'AW (Aruba)'),
('AX', 'AX (Åland Islands)'),
('AZ', 'AZ (Azerbaijan)'),
('BA', 'BA (Bosnia and Herzegovina)'),
('BB', 'BB (Barbados)'),
('BD', 'BD (Bangladesh)'),
('BE', 'BE (Belgium)'),
('BF', 'BF (Burkina Faso)'),
('BG', 'BG (Bulgaria)'),
('BH', 'BH (Bahrain)'),
('BI', 'BI (Burundi)'),
('BJ', 'BJ (Benin)'),
('BL', 'BL (Saint Barthélemy)'),
('BM', 'BM (Bermuda)'),
('BN', 'BN (Brunei Darussalam)'),
('BO', 'BO (Bolivia, Plurinational State of)'),
('BQ', 'BQ (Bonaire, Sint Eustatius and Saba)'),
('BR', 'BR (Brazil)'),
('BS', 'BS (Bahamas)'),
('BT', 'BT (Bhutan)'),
('BV', 'BV (Bouvet Island)'),
('BW', 'BW (Botswana)'),
('BY', 'BY (Belarus)'),
('BZ', 'BZ (Belize)'),
('CA', 'CA (Canada)'),
('CC', 'CC (Cocos (Keeling) Islands)'),
('CD', 'CD (Congo, the Democratic Republic of the)'),
('CF', 'CF (Central African Republic)'),
('CG', 'CG (Congo)'),
('CH', 'CH (Switzerland)'),
('CI', "CI (Côte d'Ivoire)"),
('CK', 'CK (Cook Islands)'),
('CL', 'CL (Chile)'),
('CM', 'CM (Cameroon)'),
('CN', 'CN (China)'),
('CO', 'CO (Colombia)'),
('CR', 'CR (Costa Rica)'),
('CU', 'CU (Cuba)'),
('CV', 'CV (Cape Verde)'),
('CW', 'CW (Curaçao)'),
('CX', 'CX (Christmas Island)'),
('CY', 'CY (Cyprus)'),
('CZ', 'CZ (Czech Republic)'),
('DE', 'DE (Germany)'),
('DJ', 'DJ (Djibouti)'),
('DK', 'DK (Denmark)'),
('DM', 'DM (Dominica)'),
('DO', 'DO (Dominican Republic)'),
('DZ', 'DZ (Algeria)'),
('EC', 'EC (Ecuador)'),
('EE', 'EE (Estonia)'),
('EG', 'EG (Egypt)'),
('EH', 'EH (Western Sahara)'),
('ER', 'ER (Eritrea)'),
('ES', 'ES (Spain)'),
('ET', 'ET (Ethiopia)'),
('FI', 'FI (Finland)'),
('FJ', 'FJ (Fiji)'),
('FK', 'FK (Falkland Islands (Malvinas))'),
('FM', 'FM (Micronesia, Federated States of)'),
('FO', 'FO (Faroe Islands)'),
('FR', 'FR (France)'),
('GA', 'GA (Gabon)'),
('GB', 'GB (United Kingdom)'),
('GD', 'GD (Grenada)'),
('GE', 'GE (Georgia)'),
('GF', 'GF (French Guiana)'),
('GG', 'GG (Guernsey)'),
('GH', 'GH (Ghana)'),
('GI', 'GI (Gibraltar)'),
('GL', 'GL (Greenland)'),
('GM', 'GM (Gambia)'),
('GN', 'GN (Guinea)'),
('GP', 'GP (Guadeloupe)'),
('GQ', 'GQ (Equatorial Guinea)'),
('GR', 'GR (Greece)'),
('GS', 'GS (South Georgia and the South Sandwich Islands)'),
('GT', 'GT (Guatemala)'),
('GU', 'GU (Guam)'),
('GW', 'GW (Guinea-Bissau)'),
('GY', 'GY (Guyana)'),
('HK', 'HK (Hong Kong)'),
('HM', 'HM (Heard Island and McDonald Islands)'),
('HN', 'HN (Honduras)'),
('HR', 'HR (Croatia)'),
('HT', 'HT (Haiti)'),
('HU', 'HU (Hungary)'),
('ID', 'ID (Indonesia)'),
('IE', 'IE (Ireland)'),
('IL', 'IL (Israel)'),
('IM', 'IM (Isle of Man)'),
('IN', 'IN (India)'),
('IO', 'IO (British Indian Ocean Territory)'),
('IQ', 'IQ (Iraq)'),
('IR', 'IR (Iran, Islamic Republic of)'),
('IS', 'IS (Iceland)'),
('IT', 'IT (Italy)'),
('JE', 'JE (Jersey)'),
('JM', 'JM (Jamaica)'),
('JO', 'JO (Jordan)'),
('JP', 'JP (Japan)'),
('KE', 'KE (Kenya)'),
('KG', 'KG (Kyrgyzstan)'),
('KH', 'KH (Cambodia)'),
('KI', 'KI (Kiribati)'),
('KM', 'KM (Comoros)'),
('KN', 'KN (Saint Kitts and Nevis)'),
('KP', "KP (Korea, Democratic People's Republic of)"),
('KR', 'KR (Korea, Republic of)'),
('KW', 'KW (Kuwait)'),
('KY', 'KY (Cayman Islands)'),
('KZ', 'KZ (Kazakhstan)'),
('LA', "LA (Lao People's Democratic Republic)"),
('LB', 'LB (Lebanon)'),
('LC', 'LC (Saint Lucia)'),
('LI', 'LI (Liechtenstein)'),
('LK', 'LK (Sri Lanka)'),
('LR', 'LR (Liberia)'),
('LS', 'LS (Lesotho)'),
('LT', 'LT (Lithuania)'),
('LU', 'LU (Luxembourg)'),
('LV', 'LV (Latvia)'),
('LY', 'LY (Libya)'),
('MA', 'MA (Morocco)'),
('MC', 'MC (Monaco)'),
('MD', 'MD (Moldova, Republic of)'),
('ME', 'ME (Montenegro)'),
('MF', 'MF (Saint Martin (French part))'),
('MG', 'MG (Madagascar)'),
('MH', 'MH (Marshall Islands)'),
('MK', 'MK (Macedonia, the Former Yugoslav Republic of)'),
('ML', 'ML (Mali)'),
('MM', 'MM (Myanmar)'),
('MN', 'MN (Mongolia)'),
('MO', 'MO (Macao)'),
('MP', 'MP (Northern Mariana Islands)'),
('MQ', 'MQ (Martinique)'),
('MR', 'MR (Mauritania)'),
('MS', 'MS (Montserrat)'),
('MT', 'MT (Malta)'),
('MU', 'MU (Mauritius)'),
('MV', 'MV (Maldives)'),
('MW', 'MW (Malawi)'),
('MX', 'MX (Mexico)'),
('MY', 'MY (Malaysia)'),
('MZ', 'MZ (Mozambique)'),
('NA', 'NA (Namibia)'),
('NC', 'NC (New Caledonia)'),
('NE', 'NE (Niger)'),
('NF', 'NF (Norfolk Island)'),
('NG', 'NG (Nigeria)'),
('NI', 'NI (Nicaragua)'),
('NL', 'NL (Netherlands)'),
('NO', 'NO (Norway)'),
('NP', 'NP (Nepal)'),
('NR', 'NR (Nauru)'),
('NU', 'NU (Niue)'),
('NZ', 'NZ (New Zealand)'),
('OM', 'OM (Oman)'),
('PA', 'PA (Panama)'),
('PE', 'PE (Peru)'),
('PF', 'PF (French Polynesia)'),
('PG', 'PG (Papua New Guinea)'),
('PH', 'PH (Philippines)'),
('PK', 'PK (Pakistan)'),
('PL', 'PL (Poland)'),
('PM', 'PM (Saint Pierre and Miquelon)'),
('PN', 'PN (Pitcairn)'),
('PR', 'PR (Puerto Rico)'),
('PS', 'PS (Palestine, State of)'),
('PT', 'PT (Portugal)'),
('PW', 'PW (Palau)'),
('PY', 'PY (Paraguay)'),
('QA', 'QA (Qatar)'),
('RE', 'RE (Réunion)'),
('RO', 'RO (Romania)'),
('RS', 'RS (Serbia)'),
('RU', 'RU (Russian Federation)'),
('RW', 'RW (Rwanda)'),
('SA', 'SA (Saudi Arabia)'),
('SB', 'SB (Solomon Islands)'),
('SC', 'SC (Seychelles)'),
('SD', 'SD (Sudan)'),
('SE', 'SE (Sweden)'),
('SG', 'SG (Singapore)'),
('SH', 'SH (Saint Helena, Ascension and Tristan da Cunha)'),
('SI', 'SI (Slovenia)'),
('SJ', 'SJ (Svalbard and Jan Mayen)'),
('SK', 'SK (Slovakia)'),
('SL', 'SL (Sierra Leone)'),
('SM', 'SM (San Marino)'),
('SN', 'SN (Senegal)'),
('SO', 'SO (Somalia)'),
('SR', 'SR (Suriname)'),
('SS', 'SS (South Sudan)'),
('ST', 'ST (Sao Tome and Principe)'),
('SV', 'SV (El Salvador)'),
('SX', 'SX (Sint Maarten (Dutch part))'),
('SY', 'SY (Syrian Arab Republic)'),
('SZ', 'SZ (Swaziland)'),
('TC', 'TC (Turks and Caicos Islands)'),
('TD', 'TD (Chad)'),
('TF', 'TF (French Southern Territories)'),
('TG', 'TG (Togo)'),
('TH', 'TH (Thailand)'),
('TJ', 'TJ (Tajikistan)'),
('TK', 'TK (Tokelau)'),
('TL', 'TL (Timor-Leste)'),
('TM', 'TM (Turkmenistan)'),
('TN', 'TN (Tunisia)'),
('TO', 'TO (Tonga)'),
('TR', 'TR (Turkey)'),
('TT', 'TT (Trinidad and Tobago)'),
('TV', 'TV (Tuvalu)'),
('TW', 'TW (Taiwan, Province of China)'),
('TZ', 'TZ (Tanzania, United Republic of)'),
('UA', 'UA (Ukraine)'),
('UG', 'UG (Uganda)'),
('UM', 'UM (United States Minor Outlying Islands)'),
('US', 'US (United States)'),
('UY', 'UY (Uruguay)'),
('UZ', 'UZ (Uzbekistan)'),
('VA', 'VA (Holy See (Vatican City State))'),
('VC', 'VC (Saint Vincent and the Grenadines)'),
('VE', 'VE (Venezuela, Bolivarian Republic of)'),
('VG', 'VG (Virgin Islands, British)'),
('VI', 'VI (Virgin Islands, U.S.)'),
('VN', 'VN (Viet Nam)'),
('VU', 'VU (Vanuatu)'),
('WF', 'WF (Wallis and Futuna)'),
('WS', 'WS (Samoa)'),
('YE', 'YE (Yemen)'),
('YT', 'YT (Mayotte)'),
('ZA', 'ZA (South Africa)'),
('ZM', 'ZM (Zambia)'),
('ZW', 'ZW (Zimbabwe)')
]

111557
netbox/extras/data/un_locode.py Normal file

File diff suppressed because it is too large Load Diff

View File

@ -114,7 +114,7 @@ class CustomFieldChoiceSetFilterSet(BaseFilterSet):
class Meta:
model = CustomFieldChoiceSet
fields = [
'id', 'name', 'description', 'order_alphabetically',
'id', 'name', 'description', 'base_choices', 'order_alphabetically',
]
def search(self, queryset, name, value):

View File

@ -68,6 +68,10 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
queryset=CustomFieldChoiceSet.objects.all(),
widget=forms.MultipleHiddenInput
)
base_choices = forms.ChoiceField(
choices=add_blank_choice(CustomFieldChoiceSetBaseChoices),
required=False
)
description = forms.CharField(
required=False
)
@ -76,7 +80,7 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect()
)
nullable_fields = ('description',)
nullable_fields = ('base_choices', 'description')
class CustomLinkBulkEditForm(BulkEditForm):

View File

@ -4,7 +4,7 @@ from django.contrib.postgres.forms import SimpleArrayField
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
from extras.choices import *
from extras.models import *
from extras.utils import FeatureQuery
from netbox.forms import NetBoxModelImportForm
@ -68,6 +68,11 @@ class CustomFieldImportForm(CSVModelForm):
class CustomFieldChoiceSetImportForm(CSVModelForm):
base_choices = CSVChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False,
help_text=_('The base set of predefined choices to use (if any)')
)
extra_choices = SimpleArrayField(
base_field=forms.CharField(),
required=False,

View File

@ -11,7 +11,9 @@ from extras.utils import FeatureQuery
from netbox.forms.base import NetBoxModelFilterSetForm
from tenancy.models import Tenant, TenantGroup
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
)
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .mixins import SavedFiltersMixin
@ -88,7 +90,12 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
fieldsets = (
(None, ('q', 'filter_id', 'choice')),
(None, ('q', 'filter_id')),
(_('Choices'), ('base_choices', 'choice')),
)
base_choices = forms.MultipleChoiceField(
choices=CustomFieldChoiceSetBaseChoices,
required=False
)
choice = forms.CharField(
required=False

View File

@ -19,7 +19,7 @@ from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, SlugField,
)
from utilities.forms.widgets import ArrayWidget
from utilities.forms.widgets import ChoicesWidget
from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -88,16 +88,22 @@ class CustomFieldForm(BootstrapMixin, forms.ModelForm):
class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
extra_choices = forms.CharField(
widget=ArrayWidget(),
help_text=_('Enter one choice per line.')
widget=ChoicesWidget(),
)
class Meta:
model = CustomFieldChoiceSet
fields = ('name', 'description', 'extra_choices', 'order_alphabetically')
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
def clean_extra_choices(self):
return self.cleaned_data['extra_choices'].splitlines()
data = []
for line in self.cleaned_data['extra_choices'].splitlines():
try:
value, label = line.split(',', maxsplit=1)
except ValueError:
value, label = line, line
data.append((value, label))
return data
class CustomLinkForm(BootstrapMixin, forms.ModelForm):

View File

@ -19,7 +19,7 @@ def create_choice_sets(apps, schema_editor):
for cf in choice_fields:
choiceset = CustomFieldChoiceSet.objects.create(
name=f'{cf.name} Choices',
extra_choices=cf.choices
extra_choices=tuple(zip(cf.choices, cf.choices)) # Convert list to tuple of two-tuples
)
cf.choice_set = choiceset
@ -42,7 +42,8 @@ class Migration(migrations.Migration):
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=None)),
('base_choices', models.CharField(blank=True, max_length=50)),
('extra_choices', django.contrib.postgres.fields.ArrayField(base_field=django.contrib.postgres.fields.ArrayField(base_field=models.CharField(max_length=100), size=2), blank=True, null=True, size=None)),
('order_alphabetically', models.BooleanField(default=False)),
],
options={

View File

@ -15,17 +15,18 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.choices import *
from extras.data import CHOICE_SETS
from extras.utils import FeatureQuery
from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes
from utilities import filters
from utilities.forms.fields import (
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicModelChoiceField,
DynamicModelMultipleChoiceField, JSONField, LaxURLField,
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
)
from utilities.forms.utils import add_blank_choice
from utilities.forms.widgets import DatePicker, DateTimePicker
from utilities.forms.widgets import APISelect, APISelectMultiple, DatePicker, DateTimePicker
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -410,7 +411,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Select
elif self.type in (CustomFieldTypeChoices.TYPE_SELECT, CustomFieldTypeChoices.TYPE_MULTISELECT):
choices = [(c, c) for c in self.choices]
choices = self.choice_set.choices
default_choice = self.default if self.default in self.choices else None
if not required or default_choice is None:
@ -421,11 +422,17 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
initial = default_choice
if self.type == CustomFieldTypeChoices.TYPE_SELECT:
field_class = CSVChoiceField if for_csv_import else forms.ChoiceField
field = field_class(choices=choices, required=required, initial=initial)
field_class = CSVChoiceField if for_csv_import else DynamicChoiceField
widget_class = APISelect
else:
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class(choices=choices, required=required, initial=initial)
field_class = CSVMultipleChoiceField if for_csv_import else DynamicMultipleChoiceField
widget_class = APISelectMultiple
field = field_class(
choices=choices,
required=required,
initial=initial,
widget=widget_class(api_url=f'/api/extras/custom-field-choices/{self.choice_set.pk}/choices/')
)
# URL
elif self.type == CustomFieldTypeChoices.TYPE_URL:
@ -604,14 +611,14 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Validate selected choice
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
if value not in self.choices:
if value not in [c[0] for c in self.choices]:
raise ValidationError(
f"Invalid choice ({value}). Available choices are: {', '.join(self.choices)}"
)
# Validate all selected choices
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
if not set(value).issubset(self.choices):
if not set(value).issubset([c[0] for c in self.choices]):
raise ValidationError(
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
)
@ -645,13 +652,23 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
max_length=200,
blank=True
)
base_choices = models.CharField(
max_length=50,
choices=CustomFieldChoiceSetBaseChoices,
blank=True,
help_text=_('Base set of predefined choices (optional)')
)
extra_choices = ArrayField(
base_field=models.CharField(max_length=100),
help_text=_('List of field choices')
ArrayField(
base_field=models.CharField(max_length=100),
size=2
),
blank=True,
null=True
)
order_alphabetically = models.BooleanField(
default=False,
help_text=_('Choices are automatically ordered alphabetically on save')
help_text=_('Choices are automatically ordered alphabetically')
)
clone_fields = ('extra_choices', 'order_alphabetically')
@ -667,16 +684,31 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
@property
def choices(self):
return self.extra_choices
"""
Returns a concatenation of the base and extra choices.
"""
if not hasattr(self, '_choices'):
self._choices = []
if self.base_choices:
self._choices.extend(CHOICE_SETS.get(self.base_choices))
if self.extra_choices:
self._choices.extend(self.extra_choices)
if self.order_alphabetically:
self._choices = sorted(self._choices, key=lambda x: x[0])
return self._choices
@property
def choices_count(self):
return len(self.choices)
def clean(self):
if not self.base_choices and not self.extra_choices:
raise ValidationError(_("Must define base or extra choices."))
def save(self, *args, **kwargs):
# Sort choices if alphabetical ordering is enforced
if self.order_alphabetically:
self.extra_choices = sorted(self.choices)
self.extra_choices = sorted(self.extra_choices, key=lambda x: x[0])
return super().save(*args, **kwargs)

View File

@ -66,10 +66,12 @@ class CustomFieldTable(NetBoxTable):
required = columns.BooleanColumn()
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
description = columns.MarkdownColumn()
choices = columns.ArrayColumn(
choice_set = tables.Column(
linkify=True
)
choices = columns.ChoicesColumn(
max_items=10,
orderable=False,
verbose_name=_('Choices')
orderable=False
)
is_cloneable = columns.BooleanColumn()
@ -77,8 +79,8 @@ class CustomFieldTable(NetBoxTable):
model = CustomField
fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'group_name', 'required', 'default', 'description',
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choices', 'created',
'last_updated',
'search_weight', 'filter_logic', 'ui_visibility', 'is_cloneable', 'weight', 'choice_set', 'choices',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'group_name', 'type', 'required', 'description')
@ -87,11 +89,13 @@ class CustomFieldChoiceSetTable(NetBoxTable):
name = tables.Column(
linkify=True
)
choices = columns.ArrayColumn(
base_choices = columns.ChoiceFieldColumn()
extra_choices = tables.TemplateColumn(
template_code="""{% for k, v in value.items %}{{ v }}{% if not forloop.last %}, {% endif %}{% endfor %}"""
)
choices = columns.ChoicesColumn(
max_items=10,
accessor=tables.A('extra_choices'),
orderable=False,
verbose_name=_('Choices')
orderable=False
)
choice_count = tables.TemplateColumn(
accessor=tables.A('extra_choices'),
@ -104,10 +108,10 @@ class CustomFieldChoiceSetTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = CustomFieldChoiceSet
fields = (
'pk', 'id', 'name', 'description', 'choice_count', 'choices', 'order_alphabetically', 'created',
'last_updated',
'pk', 'id', 'name', 'description', 'base_choices', 'extra_choices', 'choice_count', 'choices',
'order_alphabetically', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'choice_count', 'description')
default_columns = ('pk', 'name', 'base_choices', 'choice_count', 'description')
class CustomLinkTable(NetBoxTable):

View File

@ -139,15 +139,27 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
create_data = [
{
'name': 'Choice Set 4',
'extra_choices': ['4A', '4B', '4C'],
'extra_choices': [
['4A', 'Choice 1'],
['4B', 'Choice 2'],
['4C', 'Choice 3'],
],
},
{
'name': 'Choice Set 5',
'extra_choices': ['5A', '5B', '5C'],
'extra_choices': [
['5A', 'Choice 1'],
['5B', 'Choice 2'],
['5C', 'Choice 3'],
],
},
{
'name': 'Choice Set 6',
'extra_choices': ['6A', '6B', '6C'],
'extra_choices': [
['6A', 'Choice 1'],
['6B', 'Choice 2'],
['6C', 'Choice 3'],
],
},
]
bulk_update_data = {
@ -155,7 +167,11 @@ class CustomFieldChoiceSetTest(APIViewTestCases.APIViewTestCase):
}
update_data = {
'name': 'Choice Set X',
'extra_choices': ['X1', 'X2', 'X3'],
'extra_choices': [
['X1', 'Choice 1'],
['X2', 'Choice 2'],
['X3', 'Choice 3'],
],
'description': 'New description',
}

View File

@ -17,8 +17,8 @@ class ChangeLogViewTest(ModelViewTestCase):
@classmethod
def setUpTestData(cls):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['Bar', 'Foo']
name='Choice Set 1',
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
)
# Create a custom field on the Site model
@ -48,7 +48,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'site-1',
'status': SiteStatusChoices.STATUS_ACTIVE,
'cf_cf1': 'ABC',
'cf_cf2': 'Bar',
'cf_cf2': 'bar',
'tags': [tag.pk for tag in tags],
}
@ -84,7 +84,7 @@ class ChangeLogViewTest(ModelViewTestCase):
'slug': 'site-x',
'status': SiteStatusChoices.STATUS_PLANNED,
'cf_cf1': 'DEF',
'cf_cf2': 'Foo',
'cf_cf2': 'foo',
'tags': [tags[2].pk],
}
@ -226,7 +226,7 @@ class ChangeLogAPITest(APITestCase):
# Create a select custom field on the Site model
choice_set = CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=['Bar', 'Foo']
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'))
)
cf_select = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
@ -251,7 +251,7 @@ class ChangeLogAPITest(APITestCase):
'slug': 'site-1',
'custom_fields': {
'cf1': 'ABC',
'cf2': 'Bar',
'cf2': 'bar',
},
'tags': [
{'name': 'Tag 1'},
@ -285,7 +285,7 @@ class ChangeLogAPITest(APITestCase):
'slug': 'site-x',
'custom_fields': {
'cf1': 'DEF',
'cf2': 'Foo',
'cf2': 'foo',
},
'tags': [
{'name': 'Tag 3'}

View File

@ -269,8 +269,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_select_field(self):
CHOICES = ('Option A', 'Option B', 'Option C')
value = CHOICES[1]
CHOICES = (
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
value = 'a'
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
@ -302,8 +306,12 @@ class CustomFieldTest(TestCase):
self.assertIsNone(instance.custom_field_data.get(cf.name))
def test_multiselect_field(self):
CHOICES = ['Option A', 'Option B', 'Option C']
value = [CHOICES[1], CHOICES[2]]
CHOICES = (
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
value = ['a', 'b']
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
@ -453,7 +461,7 @@ class CustomFieldAPITest(APITestCase):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Foo', 'Bar', 'Baz')
extra_choices=(('foo', 'Foo'), ('bar', 'Bar'), ('baz', 'Baz'))
)
custom_fields = (
@ -469,13 +477,13 @@ class CustomFieldAPITest(APITestCase):
CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='select_field',
default='Foo',
default='foo',
choice_set=choice_set
),
CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='multiselect_field',
default=['Foo'],
default=['foo'],
choice_set=choice_set
),
CustomField(
@ -514,8 +522,8 @@ class CustomFieldAPITest(APITestCase):
custom_fields[6].name: '2020-01-02 12:00:00',
custom_fields[7].name: 'http://example.com/2',
custom_fields[8].name: '{"foo": 1, "bar": 2}',
custom_fields[9].name: 'Bar',
custom_fields[10].name: ['Bar', 'Baz'],
custom_fields[9].name: 'bar',
custom_fields[10].name: ['bar', 'baz'],
custom_fields[11].name: vlans[1].pk,
custom_fields[12].name: [vlans[2].pk, vlans[3].pk],
}
@ -671,8 +679,8 @@ class CustomFieldAPITest(APITestCase):
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'select_field': 'bar',
'multiselect_field': ['bar', 'baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
},
@ -799,8 +807,8 @@ class CustomFieldAPITest(APITestCase):
'datetime_field': datetime.datetime(2020, 1, 2, 12, 0, 0),
'url_field': 'http://example.com/2',
'json_field': '{"foo": 1, "bar": 2}',
'select_field': 'Bar',
'multiselect_field': ['Bar', 'Baz'],
'select_field': 'bar',
'multiselect_field': ['bar', 'baz'],
'object_field': VLAN.objects.get(vid=2).pk,
'multiobject_field': list(VLAN.objects.filter(vid__in=[3, 4]).values_list('pk', flat=True)),
}
@ -1041,7 +1049,11 @@ class CustomFieldImportTest(TestCase):
# Create a set of custom field choices
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('Choice A', 'Choice B', 'Choice C')
extra_choices=(
('a', 'Option A'),
('b', 'Option B'),
('c', 'Option C'),
)
)
custom_fields = (
@ -1067,8 +1079,8 @@ class CustomFieldImportTest(TestCase):
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_decimal', 'cf_boolean', 'cf_date', 'cf_datetime', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', '123.45', 'True', '2020-01-01', '2020-01-01 12:00:00', 'http://example.com/1', '{"foo": 123}', 'a', '"a,b"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', '456.78', 'False', '2020-01-02', '2020-01-02 12:00:00', 'http://example.com/2', '{"bar": 456}', 'b', '"b,c"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
@ -1089,8 +1101,8 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.cf['datetime'].isoformat(), '2020-01-01T12:00:00+00:00')
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
self.assertEqual(site1.custom_field_data['select'], 'a')
self.assertEqual(site1.custom_field_data['multiselect'], ['a', 'b'])
# Validate data for site 2
site2 = Site.objects.get(name='Site 2')
@ -1104,8 +1116,8 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.cf['datetime'].isoformat(), '2020-01-02T12:00:00+00:00')
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
self.assertEqual(site2.custom_field_data['select'], 'b')
self.assertEqual(site2.custom_field_data['multiselect'], ['b', 'c'])
# No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3')
@ -1221,7 +1233,7 @@ class CustomFieldModelFilterTest(TestCase):
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=['A', 'B', 'C', 'X']
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'), ('x', 'X'))
)
# Integer filtering

View File

@ -14,8 +14,8 @@ class CustomFieldModelFormTest(TestCase):
def setUpTestData(cls):
obj_type = ContentType.objects.get_for_model(Site)
choice_set = CustomFieldChoiceSet.objects.create(
name='Custom Field Choice Set 1',
extra_choices=('A', 'B', 'C')
name='Choice Set 1',
extra_choices=(('a', 'A'), ('b', 'B'), ('c', 'C'))
)
cf_text = CustomField.objects.create(name='text', type=CustomFieldTypeChoices.TYPE_TEXT)

View File

@ -1,3 +1,4 @@
import json
import urllib.parse
import uuid
@ -23,7 +24,11 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
site_ct = ContentType.objects.get_for_model(Site)
CustomFieldChoiceSet.objects.create(
name='Choice Set 1',
extra_choices=('A', 'B', 'C')
extra_choices=(
('A', 'A'),
('B', 'B'),
('C', 'C'),
)
)
custom_fields = (
@ -76,29 +81,38 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
def setUpTestData(cls):
choice_sets = (
CustomFieldChoiceSet(name='Choice Set 1', extra_choices=['1A', '1B', '1C', '1D', '1E']),
CustomFieldChoiceSet(name='Choice Set 2', extra_choices=['2A', '2B', '2C', '2D', '2E']),
CustomFieldChoiceSet(name='Choice Set 3', extra_choices=['3A', '3B', '3C', '3D', '3E']),
CustomFieldChoiceSet(
name='Choice Set 1',
extra_choices=(('A1', 'Choice 1'), ('A2', 'Choice 2'), ('A3', 'Choice 3'))
),
CustomFieldChoiceSet(
name='Choice Set 2',
extra_choices=(('B1', 'Choice 1'), ('B2', 'Choice 2'), ('B3', 'Choice 3'))
),
CustomFieldChoiceSet(
name='Choice Set 3',
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
),
)
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
cls.form_data = {
'name': 'Choice Set X',
'extra_choices': 'X1,X2,X3,X4,X5',
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
}
cls.csv_data = (
'name,extra_choices',
'Choice Set 4,"4A,4B,4C,4D,4E"',
'Choice Set 5,"5A,5B,5C,5D,5E"',
'Choice Set 6,"6A,6B,6C,6D,6E"',
'Choice Set 4,"D1,D2,D3"',
'Choice Set 5,"E1,E2,E3"',
'Choice Set 6,"F1,F2,F3"',
)
cls.csv_update_data = (
'id,extra_choices',
f'{choice_sets[0].pk},"1X,1Y,1Z"',
f'{choice_sets[1].pk},"2X,2Y,2Z"',
f'{choice_sets[2].pk},"3X,3Y,3Z"',
f'{choice_sets[0].pk},"A,B,C"',
f'{choice_sets[1].pk},"A,B,C"',
f'{choice_sets[2].pk},"A,B,C"',
)
cls.bulk_edit_data = {

View File

@ -24,11 +24,11 @@ class IPAddressAssignmentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == FHRPGroup:
if type(instance) is FHRPGroup:
return FHRPGroupType
if type(instance) == VMInterface:
if type(instance) is VMInterface:
return VMInterfaceType
@ -42,11 +42,11 @@ class L2VPNAssignmentType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == VLAN:
if type(instance) is VLAN:
return VLANType
if type(instance) == VMInterface:
if type(instance) is VMInterface:
return VMInterfaceType
@ -59,9 +59,9 @@ class FHRPGroupInterfaceType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == Interface:
if type(instance) is Interface:
return InterfaceType
if type(instance) == VMInterface:
if type(instance) is VMInterface:
return VMInterfaceType
@ -79,17 +79,17 @@ class VLANGroupScopeType(graphene.Union):
@classmethod
def resolve_type(cls, instance, info):
if type(instance) == Cluster:
if type(instance) is Cluster:
return ClusterType
if type(instance) == ClusterGroup:
if type(instance) is ClusterGroup:
return ClusterGroupType
if type(instance) == Location:
if type(instance) is Location:
return LocationType
if type(instance) == Rack:
if type(instance) is Rack:
return RackType
if type(instance) == Region:
if type(instance) is Region:
return RegionType
if type(instance) == Site:
if type(instance) is Site:
return SiteType
if type(instance) == SiteGroup:
if type(instance) is SiteGroup:
return SiteGroupType

View File

@ -129,7 +129,7 @@ def add_available_vlans(vlans, vlan_group=None):
})
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
return vlans

View File

@ -76,6 +76,18 @@ class ObjectPermissionMixin:
"""
Return all permissions granted to the user by an ObjectPermission.
"""
# Initialize a dictionary mapping permission names to sets of constraints
perms = defaultdict(list)
# Collect any configured default permissions
for perm_name, constraints in settings.DEFAULT_PERMISSIONS.items():
constraints = constraints or tuple()
if type(constraints) not in (list, tuple):
raise ImproperlyConfigured(
f"Constraints for default permission {perm_name} must be defined as a list or tuple."
)
perms[perm_name].extend(constraints)
# Retrieve all assigned and enabled ObjectPermissions
object_permissions = ObjectPermission.objects.filter(
self.get_permission_filter(user_obj),
@ -83,7 +95,6 @@ class ObjectPermissionMixin:
).order_by('id').distinct('id').prefetch_related('object_types')
# Create a dictionary mapping permissions to their constraints
perms = defaultdict(list)
for obj_perm in object_permissions:
for object_type in obj_perm.object_types.all():
for action in obj_perm.actions:
@ -119,7 +130,7 @@ class ObjectPermissionMixin:
return True
# Sanity check: Ensure that the requested permission applies to the specified object
model = obj._meta.model
model = obj._meta.concrete_model
if model._meta.label_lower != '.'.join((app_label, model_name)):
raise ValueError(f"Invalid permission {perm} for model {model}")

View File

@ -39,6 +39,8 @@ REDIS = {
SECRET_KEY = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
DEFAULT_PERMISSIONS = {}
LOGGING = {
'version': 1,
'disable_existing_loggers': True

View File

@ -99,6 +99,13 @@ DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
DEBUG = getattr(configuration, 'DEBUG', False)
DEFAULT_DASHBOARD = getattr(configuration, 'DEFAULT_DASHBOARD', None)
DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', {
# Permit users to manage their own API tokens
'users.view_token': ({'user': '$user'},),
'users.add_token': ({'user': '$user'},),
'users.change_token': ({'user': '$user'},),
'users.delete_token': ({'user': '$user'},),
})
DEVELOPER = getattr(configuration, 'DEVELOPER', False)
DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs'))
EMAIL = getattr(configuration, 'EMAIL', {})
@ -356,6 +363,7 @@ INSTALLED_APPS = [
'taggit',
'timezone_field',
'core',
'account',
'circuits',
'dcim',
'ipam',

View File

@ -9,13 +9,14 @@ from django.db.models import DateField, DateTimeField
from django.template import Context, Template
from django.urls import reverse
from django.utils.dateparse import parse_date
from django.utils.html import escape
from django.utils.formats import date_format
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django_tables2.columns import library
from django_tables2.utils import Accessor
from extras.choices import CustomFieldTypeChoices
from utilities.permissions import get_permission_for_model
from utilities.templatetags.builtins.filters import render_markdown
from utilities.utils import content_type_identifier, content_type_name, get_viewname
@ -24,6 +25,7 @@ __all__ = (
'ArrayColumn',
'BooleanColumn',
'ChoiceFieldColumn',
'ChoicesColumn',
'ColorColumn',
'ColoredLabelColumn',
'ContentTypeColumn',
@ -249,7 +251,7 @@ class ActionsColumn(tables.Column):
dropdown_links = []
user = getattr(request, 'user', AnonymousUser())
for idx, (action, attrs) in enumerate(self.actions.items()):
permission = f'{model._meta.app_label}.{attrs.permission}_{model._meta.model_name}'
permission = get_permission_for_model(model, attrs.permission)
if attrs.permission is None or user.has_perm(permission):
url = reverse(get_viewname(model, action), kwargs={'pk': record.pk})
@ -598,16 +600,49 @@ class ArrayColumn(tables.Column):
"""
List array items as a comma-separated list.
"""
def __init__(self, *args, max_items=None, func=str, **kwargs):
self.max_items = max_items
self.func = func
super().__init__(*args, **kwargs)
def render(self, value):
omitted_count = 0
# Limit the returned items to the specified maximum number (if any)
if self.max_items:
omitted_count = len(value) - self.max_items
value = value[:self.max_items - 1]
# Apply custom processing function (if any) per item
if self.func:
value = [self.func(v) for v in value]
# Annotate omitted items (if applicable)
if omitted_count > 0:
value.append(f'({omitted_count} more)')
return ', '.join(value)
class ChoicesColumn(tables.Column):
"""
Display the human-friendly labels of a set of choices.
"""
def __init__(self, *args, max_items=None, **kwargs):
self.max_items = max_items
super().__init__(*args, **kwargs)
def render(self, value):
omitted_count = 0
value = [v[1] for v in value]
# Limit the returned items to the specified maximum number (if any)
if self.max_items:
# Limit the returned items to the specified maximum number
omitted = len(value) - self.max_items
omitted_count = len(value) - self.max_items
value = value[:self.max_items - 1]
if omitted > 0:
value.append(f'({omitted} more)')
# Annotate omitted items (if applicable)
if omitted_count > 0:
value.append(f'({omitted_count} more)')
return ', '.join(value)

View File

@ -1,19 +1,18 @@
from django.conf import settings
from django.conf.urls import include
from django.urls import path, re_path
from django.urls import path
from django.views.decorators.csrf import csrf_exempt
from django.views.static import serve
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
from account.views import LoginView, LogoutView
from extras.plugins.urls import plugin_admin_patterns, plugin_patterns, plugin_api_patterns
from netbox.api.views import APIRootView, StatusView
from netbox.graphql.schema import schema
from netbox.graphql.views import GraphQLView
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
from users.views import LoginView, LogoutView
from .admin import admin_site
_patterns = [
# Base views
@ -37,7 +36,7 @@ _patterns = [
path('wireless/', include('wireless.urls')),
# Current user views
path('user/', include('users.account_urls')),
path('user/', include('account.urls')),
# HTMX views
path('htmx/object-selector/', htmx.ObjectSelectorView.as_view(), name='htmx_object_selector'),

View File

@ -1,9 +1,10 @@
{% extends 'base/40x.html' %}
{% load i18n %}
{% block title %}Access Denied{% endblock %}
{% block title %}{% trans "Access Denied" %}{% endblock %}
{% block icon %}<i class="mdi mdi-lock"></i>{% endblock %}
{% block message %}
You do not have permission to access this page.
{% trans "You do not have permission to access this page" %}.
{% endblock %}

View File

@ -1,9 +1,10 @@
{% extends 'base/40x.html' %}
{% load i18n %}
{% block title %}Page Not Found{% endblock %}
{% block title %}{% trans "Page Not Found" %}{% endblock %}
{% block icon %}<i class="mdi mdi-alert"></i>{% endblock %}
{% block message %}
The requested page does not exist.
{% trans "The requested page does not exist" %}.
{% endblock %}

View File

@ -1,9 +1,10 @@
{% load i18n %}
{% load static %}
<!DOCTYPE html>
<html lang="en">
<head>
<title>Server Error</title>
<title>{% trans "Server Error" %}</title>
<link rel="stylesheet" href="{% static 'netbox-light.css'%}" />
<meta charset="UTF-8">
</head>
@ -14,28 +15,28 @@
<div class="col col-md-6 offset-md-3">
<div class="card border-danger mt-5">
<h5 class="card-header">
<i class="mdi mdi-alert"></i> Server Error
<i class="mdi mdi-alert"></i> {% trans "Server Error" %}
</h5>
<div class="card-body">
{% block message %}
<p>
There was a problem with your request. Please contact an administrator.
{% trans "There was a problem with your request. Please contact an administrator" %}.
</p>
{% endblock %}
<hr />
<p>
The complete exception is provided below:
{% trans "The complete exception is provided below" %}:
</p>
<pre class="block"><strong>{{ exception }}</strong><br />
{{ error }}
Python version: {{ python_version }}
NetBox version: {{ netbox_version }}</pre>
{% trans "Python version" %}: {{ python_version }}
{% trans "NetBox version" %}: {{ netbox_version }}</pre>
<p>
If further assistance is required, please post to the <a href="https://github.com/netbox-community/netbox/discussions">NetBox discussion forum</a> on GitHub.
{% trans "If further assistance is required, please post to the" %} <a href="https://github.com/netbox-community/netbox/discussions">{% trans "NetBox discussion forum" %}</a> {% trans "on GitHub" %}.
</p>
<div class="text-end">
<a href="{% url 'home' %}" class="btn btn-primary">Home Page</a>
<a href="{% url 'home' %}" class="btn btn-primary">{% trans "Home Page" %}</a>
</div>
</div>
</div>

View File

@ -1,12 +1,12 @@
{% extends 'users/account/base.html' %}
{% extends 'account/base.html' %}
{% load buttons %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}Bookmarks{% endblock %}
{% block title %}{% trans "Bookmarks" %}{% endblock %}
{% block content %}
<form method="post" class="form form-horizontal">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% url 'account:bookmarks' %}" />

View File

@ -0,0 +1,21 @@
{% extends 'account/base.html' %}
{% load form_helpers %}
{% load i18n %}
{% block title %}{% trans "Change Password" %}{% endblock %}
{% block content %}
<form action="." method="post" class="form form-horizontal col-md-8 offset-md-2">
{% csrf_token %}
<div class="field-group">
<h5 class="text-center">{% trans "Password" %}</h5>
{% render_field form.old_password %}
{% render_field form.new_password1 %}
{% render_field form.new_password2 %}
</div>
<div class="text-end">
<a href="{% url 'account:profile' %}" class="btn btn-outline-danger">{% trans "Cancel" %}</a>
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</form>
{% endblock %}

View File

@ -1,8 +1,9 @@
{% extends 'users/account/base.html' %}
{% extends 'account/base.html' %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{% block title %}User Preferences{% endblock %}
{% block title %}{% trans "User Preferences" %}{% endblock %}
{% block content %}
<form method="post" action="" id="preferences-update">
@ -25,7 +26,7 @@
{% if plugin_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Plugins</h5>
<h5 class="offset-sm-3">{% trans "Plugins" %}</h5>
</div>
{% for name in plugin_fields %}
{% render_field form|getfield:name %}
@ -37,23 +38,23 @@
{# Table configurations #}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Table Configurations</h5>
<h5 class="offset-sm-3">{% trans "Table Configurations" %}</h5>
</div>
<div class="row">
{% if request.user.config.data.tables %}
<label class="col-sm-3 col-form-label text-lg-end">
Clear table preferences
{% trans "Clear table preferences" %}
</label>
<div class="col-sm-9">
<table class="table table-hover object-list">
<thead>
<tr>
<th>
<input type="checkbox" class="toggle form-check-input" title="Toggle All">
<input type="checkbox" class="toggle form-check-input" title="{% trans "Toggle All" %}">
</th>
<th>Table</th>
<th>Ordering</th>
<th>Columns</th>
<th>{% trans "Table" %}</th>
<th>{% trans "Ordering" %}</th>
<th>{% trans "Columns" %}</th>
</tr>
</thead>
<tbody>
@ -72,15 +73,14 @@
</div>
{% else %}
<div class="offset-sm-3">
<p class="text-muted">None found</p>
<p class="text-muted">{% trans "None found" %}</p>
</div>
{% endif %}
</div>
</div>
<div class="text-end my-3">
<a class="btn btn-outline-secondary" href="{% url 'account:preferences' %}">Cancel</a>
<button type="submit" name="_update" class="btn btn-primary">Save </button>
<a class="btn btn-outline-secondary" href="{% url 'account:preferences' %}">{% trans "Cancel" %}</a>
<button type="submit" name="_update" class="btn btn-primary">{% trans "Save" %}</button>
</div>
</form>
{% endblock %}

View File

@ -1,22 +1,23 @@
{% extends 'users/account/base.html' %}
{% extends 'account/base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}User Profile{% endblock %}
{% block title %}{% trans "User Profile" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-md-6">
<div class="card">
<h5 class="card-header">Account Details</h5>
<h5 class="card-header">{% trans "Account Details" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Username</th>
<th scope="row">{% trans "Username" %}</th>
<td>{{ request.user.username }}</td>
</tr>
<tr>
<th scope="row">Full Name</th>
<th scope="row">{% trans "Full Name" %}</th>
<td>
{% if request.user.first_name or request.user.last_name %}
{{ request.user.first_name }} {{ request.user.last_name }}
@ -26,19 +27,19 @@
</td>
</tr>
<tr>
<th scope="row">Email</th>
<th scope="row">{% trans "Email" %}</th>
<td>{{ request.user.email|placeholder }}</td>
</tr>
<tr>
<th scope="row">Account Created</th>
<th scope="row">{% trans "Account Created" %}</th>
<td>{{ request.user.date_joined|annotated_date }}</td>
</tr>
<tr>
<th scope="row">Superuser</th>
<th scope="row">{% trans "Superuser" %}</th>
<td>{% checkmark request.user.is_superuser %}</td>
</tr>
<tr>
<th scope="row">Admin Access</th>
<th scope="row">{% trans "Admin Access" %}</th>
<td>{% checkmark request.user.is_staff %}</td>
</tr>
</table>
@ -47,12 +48,12 @@
</div>
<div class="col-md-6">
<div class="card">
<h5 class="card-header">Assigned Groups</h5>
<h5 class="card-header">{% trans "Assigned Groups" %}</h5>
<ul class="list-group list-group-flush">
{% for group in request.user.groups.all %}
<li class="list-group-item">{{ group }}</li>
{% empty %}
<li class="list-group-item text-muted">None</li>
<li class="list-group-item text-muted">{% trans "None" %}</li>
{% endfor %}
</ul>
</div>
@ -62,7 +63,7 @@
<div class="row">
<div class="col-md-12">
<div class="card">
<h5 class="card-header text-center">Recent Activity</h5>
<h5 class="card-header text-center">{% trans "Recent Activity" %}</h5>
<div class="card-body table-responsive">
{% render_table changelog_table 'inc/table.html' %}
</div>

View File

@ -0,0 +1,26 @@
{% extends 'account/base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block title %}{% trans "My API Tokens" %}{% endblock %}
{% block content %}
<div class="row">
<div class="col col-md-12 text-end">
<a href="{% url 'account:usertoken_add' %}" class="btn btn-sm btn-primary my-3">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a Token" %}
</a>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<div class="card-body table-responsive">
{% render_table table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@ -1,4 +1,5 @@
{% extends "admin/index.html" %}
{% load i18n %}
{% block content_title %}{% endblock %}
@ -6,16 +7,16 @@
{{ block.super }}
<div class="module">
<table style="width: 100%">
<caption>System</caption>
<caption>{% trans "System" %}</caption>
<tbody>
<tr>
<th>
<a href="{% url 'rq_home' %}">Background Tasks</a>
<a href="{% url 'rq_home' %}">{% trans "Background Tasks" %}</a>
</th>
</tr>
<tr>
<th>
<a href="{% url 'plugins_list' %}">Installed plugins</a>
<a href="{% url 'plugins_list' %}">{% trans "Installed plugins" %}</a>
</th>
</tr>
</tbody>

View File

@ -1,4 +1,5 @@
{% extends 'base/layout.html' %}
{% load i18n %}
{% block title %}{% endblock %}
@ -13,7 +14,7 @@
{% block message %}{% endblock %}
</div>
<div class="card-footer text-end">
<a href="{% url 'home' %}" class="btn btn-sm btn-primary">Home Page</a>
<a href="{% url 'home' %}" class="btn btn-sm btn-primary">{% trans "Home Page" %}</a>
</div>
</div>
</div>

View File

@ -1,6 +1,7 @@
{# Base template for (almost) all NetBox pages #}
{% load static %}
{% load helpers %}
{% load i18n %}
<!DOCTYPE html>
<html
lang="en"
@ -24,7 +25,7 @@
/>
{# Page title #}
<title>{% block title %}Home{% endblock %} | NetBox</title>
<title>{% block title %}{% trans "Home" %}{% endblock %} | NetBox</title>
<script
type="text/javascript"

View File

@ -2,6 +2,7 @@
{% extends 'base/base.html' %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% comment %}
Blocks:
@ -23,7 +24,7 @@ Blocks:
{# NetBox Logo, only visible when printing #}
<div class="p-2 printonly">
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" width="200px" />
<img src="{% static 'netbox_logo.svg' %}" alt="{% trans "NetBox logo" %}" width="200px" />
</div>
{# Top bar #}
@ -33,7 +34,7 @@ Blocks:
<div class="nav-mobile">
<div class="nav-mobile-top">
<a class="sidebar-logo p-2 d-block" href="{% url 'home' %}">
<img src="{% static 'netbox_logo.svg' %}" alt="NetBox logo" height="40" />
<img src="{% static 'netbox_logo.svg' %}" alt="{% trans "NetBox logo" %}" height="40" />
</a>
<button type="button" aria-label="Toggle Navigation" class="navbar-toggler sidenav-toggle-mobile">
<span class="navbar-toggler-icon"></span>
@ -72,14 +73,14 @@ Blocks:
{% if settings.DEBUG and not settings.DEVELOPER %}
<div class="alert alert-warning text-center mx-3" role="alert">
<strong><i class="mdi mdi-alert"></i> Debug mode is enabled.</strong>
Performance may be limited. Debugging should never be enabled on a production system.
<strong><i class="mdi mdi-alert"></i> {% trans "Debug mode is enabled" %}.</strong>
{% trans "Performance may be limited. Debugging should never be enabled on a production system" %}.
</div>
{% endif %}
{% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
<div class="alert alert-warning text-center mx-3" role="alert">
<h5><i class="mdi mdi-alert"></i> Maintenance Mode</h5>
<h5><i class="mdi mdi-alert"></i> {% trans "Maintenance Mode" %}</h5>
{{ config.BANNER_MAINTENANCE|escape }}
</div>
{% endif %}
@ -130,34 +131,34 @@ Blocks:
{% block footer_links %}
{# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
<i title="{% trans "Docs" %}" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# REST API #}
<a type="button" class="nav-link" href="{% url 'api-root' %}" target="_blank">
<i title="REST API" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
<i title="{% trans "REST API" %}" class="mdi mdi-cloud-braces text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# API docs #}
<a type="button" class="nav-link" href="{% url 'api_docs' %}" target="_blank">
<i title="REST API documentation" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
<i title="{% trans "REST API documentation" %}" class="mdi mdi-book text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# GraphQL API #}
{% if config.GRAPHQL_ENABLED %}
<a type="button" class="nav-link" href="{% url 'graphql' %}" target="_blank">
<i title="GraphQL API" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
<i title="{% trans "GraphQL API" %}" class="mdi mdi-graphql text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endif %}
{# GitHub #}
<a type="button" class="nav-link" href="https://github.com/netbox-community/netbox" target="_blank">
<i title="Source Code" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
<i title="{% trans "Source Code" %}" class="mdi mdi-github text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{# NetDev Slack #}
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
<i title="{% trans "Community" %}" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a>
{% endblock footer_links %}
</nav>

View File

@ -1,5 +1,6 @@
{% load navigation %}
{% load static %}
{% load i18n %}
<nav class="sidenav noprint" id="sidenav" data-simplebar>
<div class="sidenav-header">
@ -8,12 +9,12 @@
{# Full Logo #}
<a class="sidenav-brand" href="{% url 'home' %}">
<img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="NetBox Logo">
<img src="{% static 'netbox_logo.svg' %}" height="48" class="sidenav-brand-img" alt="{% trans "NetBox Logo" %}">
</a>
{# Icon Logo #}
<a class="sidenav-brand-icon" href="{% url 'home' %}">
<img src="{% static 'netbox_icon.svg' %}" height="32" class="sidenav-brand-img" alt="NetBox Logo">
<img src="{% static 'netbox_icon.svg' %}" height="32" class="sidenav-brand-img" alt="{% trans "NetBox Logo" %}">
</a>
{# Pin/Unpin Toggle #}

View File

@ -1,6 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -11,31 +12,31 @@
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Circuit</h5>
<h5 class="card-header">{% trans "Circuit" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Provider</th>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">Account</th>
<th scope="row">{% trans "Account" %}</th>
<td>{{ object.provider_account|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Circuit ID</th>
<th scope="row">{% trans "Circuit ID" %}</th>
<td>{{ object.cid }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.type|linkify }}</td>
</tr>
<tr>
<th scope="row">Status</th>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
@ -44,19 +45,19 @@
</td>
</tr>
<tr>
<th scope="row">Install Date</th>
<th scope="row">{% trans "Install Date" %}</th>
<td>{{ object.install_date|annotated_date|placeholder }}</td>
</tr>
<tr>
<th scope="row">Termination Date</th>
<th scope="row">{% trans "Termination Date" %}</th>
<td>{{ object.termination_date|annotated_date|placeholder }}</td>
</tr>
<tr>
<th scope="row">Commit Rate</th>
<th scope="row">{% trans "Commit Rate" %}</th>
<td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>

View File

@ -1,12 +1,13 @@
{% extends 'generic/confirmation_form.html' %}
{% load i18n %}
{% block title %}Swap Circuit Terminations{% endblock %}
{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
{% block message %}
<p>Swap these terminations for circuit {{ circuit }}?</p>
<p>{% blocktrans %}Swap these terminations for circuit {{ circuit }}?{% endblocktrans %}</p>
<ul>
<li>
<strong>A side:</strong>
<strong>{% trans "A side" %}:</strong>
{% if termination_a %}
{{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
{% else %}
@ -14,7 +15,7 @@
{% endif %}
</li>
<li>
<strong>Z side:</strong>
<strong>{% trans "Z side" %}:</strong>
{% if termination_z %}
{{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
{% else %}

View File

@ -1,11 +1,12 @@
{% extends 'generic/object_edit.html' %}
{% load static %}
{% load form_helpers %}
{% load i18n %}
{% block form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Circuit Termination</h5>
<h5 class="offset-sm-3">{% trans "Circuit Termination" %}</h5>
</div>
{% render_field form.circuit %}
{% render_field form.term_side %}
@ -16,10 +17,10 @@
<div class="offset-sm-3">
<ul class="nav nav-pills" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link{% if not providernetwork_tab_active %} active{% endif %}" role="tab" type="button" data-bs-target="#site" data-bs-toggle="tab">Site</button>
<button class="nav-link{% if not providernetwork_tab_active %} active{% endif %}" role="tab" type="button" data-bs-target="#site" data-bs-toggle="tab">{% trans "Site" %}</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link{% if providernetwork_tab_active %} active{% endif %}" role="tab" type="button" data-bs-toggle="tab" data-bs-target="#providernetwork">Provider Network</button>
<button class="nav-link{% if providernetwork_tab_active %} active{% endif %}" role="tab" type="button" data-bs-toggle="tab" data-bs-target="#providernetwork">{% trans "Provider Network" %}</button>
</li>
</ul>
</div>
@ -37,7 +38,7 @@
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Termination Details</h5>
<h5 class="offset-sm-3">{% trans "Termination Details" %}</h5>
</div>
{% render_field form.port_speed %}
{% render_field form.upstream_speed %}
@ -49,7 +50,7 @@
{% if form.custom_fields %}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>

View File

@ -2,11 +2,12 @@
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
{% if perms.circuits.add_circuit %}
<a href="{% url 'circuits:circuit_add' %}?type={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Circuit
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Circuit" %}
</a>
{% endif %}
{% endblock extra_controls %}
@ -16,16 +17,16 @@
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Circuit Type
{% trans "Circuit Type" %}
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>

View File

@ -1,35 +1,36 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<div class="card-header">
<div class="float-md-end">
{% if not termination and perms.circuits.add_circuittermination %}
<a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success lh-1">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add" %}
</a>
{% endif %}
{% if termination and perms.circuits.change_circuittermination %}
<a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning lh-1">
<span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
</a>
<a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary lh-1">
<span class="mdi mdi-swap-vertical" aria-hidden="true"></span> Swap
<span class="mdi mdi-swap-vertical" aria-hidden="true"></span> {% trans "Swap" %}
</a>
{% endif %}
{% if termination and perms.circuits.delete_circuittermination %}
<a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger lh-1">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
</a>
{% endif %}
</div>
<h5>Termination {{ side }}</h5>
<h5>{% blocktrans %}Termination {{ side }}{% endblocktrans %}</h5>
</div>
<div class="card-body">
{% if termination %}
<table class="table table-hover attr-table">
{% if termination.site %}
<tr>
<td>Site</td>
<td>{% trans "Site" %}</td>
<td>
{% if termination.site.region %}
{{ termination.site.region|linkify }} /
@ -38,13 +39,13 @@
</td>
</tr>
<tr>
<td>Termination</td>
<td>{% trans "Termination" %}</td>
<td>
{% if termination.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
<span class="text-muted">Marked as connected</span>
<span class="text-muted">{% trans "Marked as connected" %}</span>
{% elif termination.cable %}
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> to
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
{% for peer in termination.link_peers %}
{% if peer.device %}
{{ peer.device|linkify }}<br/>
@ -54,30 +55,30 @@
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
{% endfor %}
<div class="mt-1">
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> Trace
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary btn-sm lh-1" title="{% trans "Trace" %}">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
</a>
{% if perms.dcim.change_cable %}
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Edit cable" class="btn btn-warning btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> Edit
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
</a>
{% endif %}
{% if perms.dcim.delete_cable %}
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="Remove cable" class="btn btn-danger btn-sm lh-1">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> Disconnect
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger btn-sm lh-1">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
</a>
{% endif %}
</div>
{% elif perms.dcim.add_cable %}
<div class="dropdown">
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">Circuit Termination</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
</ul>
</div>
{% endif %}
@ -85,16 +86,16 @@
</tr>
{% else %}
<tr>
<td>Provider Network</td>
<td>{% trans "Provider Network" %}</td>
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
</tr>
{% endif %}
<tr>
<td>Speed</td>
<td>{% trans "Speed" %}</td>
<td>
{% if termination.port_speed and termination.upstream_speed %}
<i class="mdi mdi-arrow-down-bold" title="Downstream"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="Upstream"></i> {{ termination.upstream_speed|humanize_speed }}
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
{% elif termination.port_speed %}
{{ termination.port_speed|humanize_speed }}
{% else %}
@ -103,19 +104,19 @@
</td>
</tr>
<tr>
<td>Cross-Connect</td>
<td>{% trans "Cross-Connect" %}</td>
<td>{{ termination.xconnect_id|placeholder }}</td>
</tr>
<tr>
<td>Patch Panel/Port</td>
<td>{% trans "Patch Panel/Port" %}</td>
<td>{{ termination.pp_info|placeholder }}</td>
</tr>
<tr>
<td>Description</td>
<td>{% trans "Description" %}</td>
<td>{{ termination.description|placeholder }}</td>
</tr>
<tr>
<td>Tags</td>
<td>{% trans "Tags" %}</td>
<td>
{% for tag in termination.tags.all %}
{% tag tag %}
@ -150,7 +151,7 @@
{% endfor %}
</table>
{% else %}
<span class="text-muted">None</span>
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
</div>

View File

@ -3,11 +3,12 @@
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
{% if perms.circuits.add_circuit %}
<a href="{% url 'circuits:circuit_add' %}?provider={{ object.pk }}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add circuit
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add circuit" %}
</a>
{% endif %}
{% endblock extra_controls %}
@ -16,11 +17,11 @@
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Provider</h5>
<h5 class="card-header">{% trans "Provider" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">ASNs</th>
<th scope="row">{% trans "ASNs" %}</th>
<td>
{% for asn in object.asns.all %}
{{ asn|linkify }}{% if not forloop.last %}, {% endif %}
@ -30,7 +31,7 @@
</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
@ -49,7 +50,7 @@
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Provider Accounts</h5>
<h5 class="card-header">{% trans "Provider Accounts" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.pk }}"
hx-trigger="load"
@ -57,7 +58,7 @@
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Circuits</h5>
<h5 class="card-header">{% trans "Circuits" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'circuits:circuit_list' %}?provider_id={{ object.pk }}"
hx-trigger="load"

View File

@ -3,6 +3,7 @@
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -13,19 +14,19 @@
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Provider Account</h5>
<h5 class="card-header">{% trans "Provider Account" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Provider</th>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">Account</th>
<th scope="row">{% trans "Account" %}</th>
<td>{{ object.account }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
</table>
@ -42,7 +43,7 @@
</div>
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Circuits</h5>
<h5 class="card-header">{% trans "Circuits" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'circuits:circuit_list' %}?provider_account_id={{ object.pk }}"
hx-trigger="load"

View File

@ -3,6 +3,7 @@
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -14,24 +15,24 @@
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Provider Network
{% trans "Provider Network" %}
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Provider</th>
<th scope="row">{% trans "Provider" %}</th>
<td>{{ object.provider|linkify }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Service ID</th>
<th scope="row">{% trans "Service ID" %}</th>
<td>{{ object.service_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
@ -50,7 +51,7 @@
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Circuits</h5>
<h5 class="card-header">{% trans "Circuits" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'circuits:circuit_list' %}?provider_network_id={{ object.pk }}"
hx-trigger="load"

View File

@ -4,6 +4,7 @@
{% load helpers %}
{% load perms %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -28,30 +29,30 @@
<div class="row mb-3">
<div class="col">
<div class="card">
<h5 class="card-header">Data File</h5>
<h5 class="card-header">{% trans "Data File" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Source</th>
<th scope="row">{% trans "Source" %}</th>
<td>{{ object.source|linkify }}</td>
</tr>
<tr>
<th scope="row">Path</th>
<th scope="row">{% trans "Path" %}</th>
<td>
<span class="font-monospace" id="datafile_path">{{ object.path }}</span>
{% copy_content "datafile_path" %}
</td>
</tr>
<tr>
<th scope="row">Last Updated</th>
<th scope="row">{% trans "Last Updated" %}</th>
<td>{{ object.last_updated }}</td>
</tr>
<tr>
<th scope="row">Size</th>
<td>{{ object.size }} byte{{ object.size|pluralize }}</td>
<th scope="row">{% trans "Size" %}</th>
<td>{{ object.size }} {% trans "bytes" %}</td>
</tr>
<tr>
<th scope="row">SHA256 Hash</th>
<th scope="row">{% trans "SHA256 Hash" %}</th>
<td>
<span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
{% copy_content "datafile_hash" %}
@ -61,7 +62,7 @@
</div>
</div>
<div class="card">
<h5 class="card-header">Content</h5>
<h5 class="card-header">{% trans "Content" %}</h5>
<div class="card-body">
<pre>{{ object.data_as_string }}</pre>
</div>

View File

@ -3,6 +3,7 @@
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block extra_controls %}
{% if perms.core.sync_datasource %}
@ -10,13 +11,13 @@
<form action="{% url 'core:datasource_sync' pk=object.pk %}" method="post">
{% csrf_token %}
<button type="submit" class="btn btn-sm btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync" %}
</button>
</form>
{% else %}
<span class="inline-block" tabindex="0" data-bs-toggle="tooltip" data-bs-delay="100" data-bs-placement="bottom">
<button class="btn btn-sm btn-primary" disabled>
<i class="mdi mdi-sync" aria-hidden="true"></i> Sync
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync" %}
</button>
</span>
{% endif %}
@ -27,35 +28,35 @@
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Data Source</h5>
<h5 class="card-header">{% trans "Data Source" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">Enabled</th>
<th scope="row">{% trans "Enabled" %}</th>
<td>{% checkmark object.enabled %}</td>
</tr>
<tr>
<th scope="row">Status</th>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Last synced</th>
<th scope="row">{% trans "Last synced" %}</th>
<td>{{ object.last_synced|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">URL</th>
<th scope="row">{% trans "URL" %}</th>
<td>
{% if not object.is_local %}
<a href="{{ object.source_url }}">{{ object.source_url }}</a>
@ -65,7 +66,7 @@
</td>
</tr>
<tr>
<th scope="row">Ignore rules</th>
<th scope="row">{% trans "Ignore rules" %}</th>
<td>
{% if object.ignore_rules %}
<pre>{{ object.ignore_rules }}</pre>
@ -82,7 +83,7 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Backend</h5>
<h5 class="card-header">{% trans "Backend" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
{% for name, field in object.get_backend.parameters.items %}
@ -97,7 +98,7 @@
{% empty %}
<tr>
<td colspan="2" class="text-muted">
No parameters defined
{% trans "No parameters defined" %}
</td>
</tr>
{% endfor %}
@ -112,7 +113,7 @@
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">Files</h5>
<h5 class="card-header">{% trans "Files" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'core:datafile_list' %}?source_id={{ object.pk }}"
hx-trigger="load"

View File

@ -2,6 +2,7 @@
{% load buttons %}
{% load helpers %}
{% load perms %}
{% load i18n %}
{% block controls %}
<div class="controls">
@ -17,25 +18,25 @@
<div class="row mb-3">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Job</h5>
<h5 class="card-header">{% trans "Job" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Object Type</th>
<th scope="row">{% trans "Object Type" %}</th>
<td>
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object_type }}</a>
</td>
</tr>
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">Status</th>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Created By</th>
<th scope="row">{% trans "Created By" %}</th>
<td>{{ object.user|placeholder }}</td>
</tr>
</table>
@ -44,23 +45,23 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Scheduling</h5>
<h5 class="card-header">{% trans "Scheduling" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Created</th>
<th scope="row">{% trans "Created" %}</th>
<td>{{ object.created|annotated_date }}</td>
</tr>
<tr>
<th scope="row">Scheduled</th>
<td>{{ object.scheduled|annotated_date|placeholder }}{% if object.interval %} (every {{ object.interval }} seconds){% endif %}</td>
<th scope="row">{% trans "Scheduled" %}</th>
<td>{{ object.scheduled|annotated_date|placeholder }}{% if object.interval %} ({% blocktrans %}every {{ object.interval }} seconds{% endblocktrans %}){% endif %}</td>
</tr>
<tr>
<th scope="row">Started</th>
<th scope="row">{% trans "Started" %}</th>
<td>{{ object.started|annotated_date|placeholder }}</td>
</tr>
<tr>
<th scope="row">Completed</th>
<th scope="row">{% trans "Completed" %}</th>
<td>{{ object.completed|annotated_date|placeholder }}</td>
</tr>
</table>
@ -71,7 +72,7 @@
<div class="row">
<div class="col col-12">
<div class="card">
<h5 class="card-header">Data</h5>
<h5 class="card-header">{% trans "Data" %}</h5>
<div class="card-body">
<pre>{{ object.data|json }}</pre>
</div>

View File

@ -1,10 +1,11 @@
{% extends 'generic/confirmation_form.html' %}
{% load helpers %}
{% load i18n %}
{% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}
{% block title %}{% trans "Disconnect" %} {{ obj_type_plural|bettertitle }}{% endblock %}
{% block message %}
<p>Are you sure you want to disconnect these {{ selected_objects|length }} {{ obj_type_plural }}?</p>
<p>{% blocktrans %}Are you sure you want to disconnect these {{ selected_objects|length }} {{ obj_type_plural }}?{% endblocktrans %}</p>
<ul>
{% for obj in selected_objects %}
<li>{{ obj }}</li>

View File

@ -3,24 +3,25 @@
{% load helpers %}
{% load perms %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Cable</h5>
<h5 class="card-header">{% trans "Cable" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Type</th>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Status</th>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
@ -29,15 +30,15 @@
</td>
</tr>
<tr>
<th scope="row">Label</th>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Color</th>
<th scope="row">{% trans "Color" %}</th>
<td>
{% if object.color %}
<span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
@ -47,7 +48,7 @@
</td>
</tr>
<tr>
<th scope="row">Length</th>
<th scope="row">{% trans "Length" %}</th>
<td>
{% if object.length %}
{{ object.length|floatformat }} {{ object.get_length_unit_display }}
@ -66,13 +67,13 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Termination A</h5>
<h5 class="card-header">{% trans "Termination" %} A</h5>
<div class="card-body">
{% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %}
</div>
</div>
<div class="card">
<h5 class="card-header">Termination B</h5>
<h5 class="card-header">{% trans "Termination" %} B</h5>
<div class="card-body">
{% include 'dcim/inc/cable_termination.html' with terminations=object.b_terminations %}
</div>

View File

@ -2,13 +2,14 @@
{% load static %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{% block form %}
{# A side termination #}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">A Side</h5>
<h5 class="offset-sm-3">{% trans "A Side" %}</h5>
</div>
{% if 'termination_a_device' in form.fields %}
{% render_field form.termination_a_device %}
@ -25,7 +26,7 @@
{# B side termination #}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">B Side</h5>
<h5 class="offset-sm-3">{% trans "B Side" %}</h5>
</div>
{% if 'termination_b_device' in form.fields %}
{% render_field form.termination_b_device %}
@ -42,7 +43,7 @@
{# Cable attributes #}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Cable</h5>
<h5 class="offset-sm-3">{% trans "Cable" %}</h5>
</div>
{% render_field form.status %}
{% render_field form.type %}
@ -64,7 +65,7 @@
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
<h5 class="offset-sm-3">{% trans "Tenancy" %}</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
@ -73,7 +74,7 @@
{% if form.custom_fields %}
<div class="field-group mb-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>
@ -81,7 +82,7 @@
{% if form.comments %}
<div class="field-group mb-5">
<h5 class="text-center">Comments</h5>
<h5 class="text-center">{% trans "Comments" %}</h5>
{% render_field form.comments %}
</div>
{% endif %}

View File

@ -1,7 +1,8 @@
{% extends 'base/layout.html' %}
{% load helpers %}
{% load i18n %}
{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}
{% block title %}{% blocktrans %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblocktrans %}{% endblock %}
{% block content %}
<div class="row">
@ -13,21 +14,21 @@
<object data="{{ svg_url }}" class="rack_elevation"></object>
<div>
<a class="btn btn-outline-primary btn-sm my-3" href="{{ svg_url }}">
<i class="mdi mdi-file-download"></i> Download SVG
<i class="mdi mdi-file-download"></i> {% trans "Download SVG" %}
</a>
</div>
</div>
<div class="trace-end">
{% if path.is_split %}
<h3 class="text-danger">Path split!</h3>
<p>Select a node below to continue:</p>
<h3 class="text-danger">{% trans "Path split" %}!</h3>
<p>{% trans "Select a node below to continue" %}:</p>
<ul class="text-start">
{% for next_node in path.get_split_nodes %}
{% if next_node.cable %}
{% with viewname=next_node|viewname:"trace" %}
<li>
<a href="{% url viewname pk=next_node.pk %}">{{ next_node|meta:"verbose_name"|bettertitle }} {{ next_node }}</a>
(Cable {{ next_node.cable|linkify }})
({% trans "Cable" %} {{ next_node.cable|linkify }})
</li>
{% endwith %}
{% else %}
@ -36,20 +37,20 @@
{% endfor %}
</ul>
{% else %}
<h3 class="text-center text-success">Trace Completed</h3>
<h3 class="text-center text-success">{% trans "Trace Completed" %}</h3>
<table class="table">
<tr>
<th scope="row">Total segments</th>
<th scope="row">{% trans "Total segments" %}</th>
<td>{{ path.segment_count }}</td>
</tr>
<tr>
<th scope="row">Total length</th>
<th scope="row">{% trans "Total length" %}</th>
<td>
{% if total_length %}
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} {% trans "Meters" %} /
{{ total_length|meters_to_feet|floatformat:"-2" }} {% trans "Feet" %}
{% else %}
<span class="text-muted">N/A</span>
<span class="text-muted">{% trans "N/A" %}</span>
{% endif %}
</td>
</tr>
@ -58,7 +59,7 @@
</div>
{% else %}
<h3 class="text-center text-muted my-3">
No paths found
{% trans "No paths found" %}
</h3>
{% endif %}
</div>
@ -67,15 +68,15 @@
<div class="col col-md-7">
<div class="card">
<h5 class="card-header">
Related Paths
{% trans "Related Paths" %}
</h5>
<div class="card-body">
<table class="table table-hover">
<thead>
<tr>
<th>Origin</th>
<th>Destination</th>
<th>Segments</th>
<th>{% trans "Origin" %}</th>
<th>{% trans "Destination" %}</th>
<th>{% trans "Segments" %}</th>
</tr>
</thead>
<tbody>
@ -88,7 +89,7 @@
{% if cablepath.destinations %}
{{ cablepath.destinations|join:", " }}
{% else %}
<span class="text-muted">Incomplete</span>
<span class="text-muted">{% trans "Incomplete" %}</span>
{% endif %}
</td>
<td>
@ -97,7 +98,7 @@
</tr>
{% empty %}
<td colspan="3" class="text-muted">
None found
{% trans "None found" %}
</td>
{% endfor %}
</tbody>

View File

@ -1,6 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -14,36 +15,36 @@
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Console Port
{% trans "Console Port" %}
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Device</th>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">Module</th>
<th scope="row">{% trans "Module" %}</th>
<td>{{ object.module|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Label</th>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">Speed</th>
<th scope="row">{% trans "Speed" %}</th>
<td>{{ object.get_speed_display }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
@ -55,29 +56,29 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Connection</h5>
<h5 class="card-header">{% trans "Connection" %}</h5>
<div class="card-body">
{% if object.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> {% trans "Marked as connected" %}
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleport_trace' %}
{% else %}
<div class="text-muted">
Not Connected
{% trans "Not Connected" %}
{% if perms.dcim.add_cable %}
<div class="dropdown float-end">
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Server Port</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Console Server Port" %}</a>
</li>
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Front Port" %}</a>
</li>
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Rear Port" %}</a>
</li>
</ul>
</div>

View File

@ -1,8 +1,9 @@
{% extends 'generic/confirmation_form.html' %}
{% load form_helpers %}
{% load i18n %}
{% block title %}Delete console port {{ consoleport }}?{% endblock %}
{% block title %}{% blocktrans %}Delete console port {{ consoleport }}?{% endblocktrans %}{% endblock %}
{% block message %}
<p>Are you sure you want to delete this console port from <strong>{{ consoleport.device }}</strong>?</p>
<p>{% blocktrans %}Are you sure you want to delete this console port from <strong>{{ consoleport.device }}</strong>?{% endblocktrans %}</p>
{% endblock %}

View File

@ -1,6 +1,7 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -14,36 +15,36 @@
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">
Console Server Port
{% trans "Console Server Port" %}
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Device</th>
<th scope="row">{% trans "Device" %}</th>
<td>{{ object.device|linkify }}</td>
</tr>
<tr>
<th scope="row">Module</th>
<th scope="row">{% trans "Module" %}</th>
<td>{{ object.module|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Name</th>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">Label</th>
<th scope="row">{% trans "Label" %}</th>
<td>{{ object.label|placeholder }}</td>
</tr>
<tr>
<th scope="row">Type</th>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Speed</th>
<th scope="row">{% trans "Speed" %}</th>
<td>{{ object.get_speed_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
@ -55,29 +56,29 @@
</div>
<div class="col col-md-6">
<div class="card">
<h5 class="card-header">Connection</h5>
<h5 class="card-header">{% trans "Connection" %}</h5>
<div class="card-body">
{% if object.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> {% trans "Marked as connected" %}
{% elif object.cable %}
{% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleserverport_trace' %}
{% else %}
<div class="text-muted">
Not Connected
{% trans "Not Connected" %}
{% if perms.dcim.add_cable %}
<div class="dropdown float-end">
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Console Port</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Console Port" %}</a>
</li>
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Front Port</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Front Port" %}</a>
</li>
<li>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">Rear Port</a>
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Rear Port" %}</a>
</li>
</ul>
</div>

View File

@ -1,8 +1,9 @@
{% extends 'generic/confirmation_form.html' %}
{% load form_helpers %}
{% load i18n %}
{% block title %}Delete console server port {{ consoleserverport }}?{% endblock %}
{% block title %}{% blocktrans %}Delete console server port {{ consoleserverport }}?{% endblocktrans %}{% endblock %}
{% block message %}
<p>Are you sure you want to delete this console server port from <strong>{{ consoleserverport.device }}</strong>?</p>
<p>{% blocktrans %}Are you sure you want to delete this console server port from <strong>{{ consoleserverport.device }}</strong>?{% endblocktrans %}</p>
{% endblock %}

View File

@ -4,16 +4,17 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-xl-6">
<div class="card">
<h5 class="card-header">Device</h5>
<h5 class="card-header">{% trans "Device" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Region</th>
<th scope="row">{% trans "Region" %}</th>
<td>
{% if object.site.region %}
{% for region in object.site.region.get_ancestors %}
@ -26,11 +27,11 @@
</td>
</tr>
<tr>
<th scope="row">Site</th>
<th scope="row">{% trans "Site" %}</th>
<td>{{ object.site|linkify }}</td>
</tr>
<tr>
<th scope="row">Location</th>
<th scope="row">{% trans "Location" %}</th>
<td>
{% if object.location %}
{% for location in object.location.get_ancestors %}
@ -43,12 +44,12 @@
</td>
</tr>
<tr>
<th scope="row">Rack</th>
<th scope="row">{% trans "Rack" %}</th>
<td class="position-relative">
{% if object.rack %}
{{ object.rack|linkify }}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm" title="Highlight device">
<a href="{{ object.rack.get_absolute_url }}?device={{ object.pk }}" class="btn btn-primary btn-sm" title="{% trans "Highlight device" %}">
<i class="mdi mdi-view-day-outline"></i>
</a>
</div>
@ -58,7 +59,7 @@
</td>
</tr>
<tr>
<th scope="row">Position</th>
<th scope="row">{% trans "Position" %}</th>
<td>
{% if object.parent_bay %}
{% with object.parent_bay.device as parent %}
@ -70,20 +71,20 @@
{% elif object.rack and object.position %}
<span>U{{ object.position|floatformat }} / {{ object.get_face_display }}</span>
{% elif object.rack and object.device_type.u_height %}
<span class="badge bg-warning">Not racked</span>
<span class="badge bg-warning">{% trans "Not racked" %}</span>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">GPS Coordinates</th>
<th scope="row">{% trans "GPS Coordinates" %}</th>
<td class="position-relative">
{% if object.latitude and object.longitude %}
{% if config.MAPS_URL %}
<div class="position-absolute top-50 end-0 translate-middle-y noprint">
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary btn-sm">
<i class="mdi mdi-map-marker"></i> Map It
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
</a>
</div>
{% endif %}
@ -94,7 +95,7 @@
</td>
</tr>
<tr>
<th scope="row">Tenant</th>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
@ -103,31 +104,31 @@
</td>
</tr>
<tr>
<th scope="row">Device Type</th>
<th scope="row">{% trans "Device Type" %}</th>
<td>
{{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U)
</td>
</tr>
<tr>
<th scope="row">Description</th>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">Airflow</th>
<th scope="row">{% trans "Airflow" %}</th>
<td>
{{ object.get_airflow_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">Serial Number</th>
<th scope="row">{% trans "Serial Number" %}</th>
<td class="font-monospace">{{ object.serial|placeholder }}</td>
</tr>
<tr>
<th scope="row">Asset Tag</th>
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">Config Template</th>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ object.config_template|linkify|placeholder }}</td>
</tr>
</table>
@ -136,15 +137,15 @@
{% if vc_members %}
<div class="card">
<h5 class="card-header">
Virtual Chassis
{% trans "Virtual Chassis" %}
</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th>Device</th>
<th>Position</th>
<th>Master</th>
<th>Priority</th>
<th>{% trans "Device" %}</th>
<th>{% trans "Position" %}</th>
<th>{% trans "Master" %}</th>
<th>{% trans "Priority" %}</th>
</tr>
{% for vc_member in vc_members %}
<tr{% if vc_member == object %} class="info"{% endif %}>
@ -166,7 +167,7 @@
</div>
<div class="card-footer text-end noprint">
<a href="{{ object.virtual_chassis.get_absolute_url }}" class="btn btn-primary btn-sm">
<span class="mdi mdi-arrow-right-bold" aria-hidden="true"></span> View Virtual Chassis
<span class="mdi mdi-arrow-right-bold" aria-hidden="true"></span> {% trans "View Virtual Chassis" %}
</a>
</div>
</div>
@ -175,7 +176,7 @@
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
<div class="card">
<h5 class="card-header">Virtual Device Contexts</h5>
<h5 class="card-header">{% trans "Virtual Device Contexts" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'dcim:virtualdevicecontext_list' %}?device_id={{ object.pk }}"
hx-trigger="load"
@ -183,7 +184,7 @@
{% if perms.dcim.add_virtualdevicecontext %}
<div class="card-footer text-end noprint">
<a href="{% url 'dcim:virtualdevicecontext_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Create VDC
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Create VDC" %}
</a>
</div>
{% endif %}
@ -192,30 +193,30 @@
</div>
<div class="col col-12 col-xl-6">
<div class="card">
<h5 class="card-header">Management</h5>
<h5 class="card-header">{% trans "Management" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Status</th>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">Role</th>
<th scope="row">{% trans "Role" %}</th>
<td>{{ object.device_role|linkify }}</td>
</tr>
<tr>
<th scope="row">Platform</th>
<th scope="row">{% trans "Platform" %}</th>
<td>{{ object.platform|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Primary IPv4</th>
<th scope="row">{% trans "Primary IPv4" %}</th>
<td>
{% if object.primary_ip4 %}
<a href="{{ object.primary_ip4.get_absolute_url }}" id="primary_ip4">{{ object.primary_ip4.address.ip }}</a>
{% if object.primary_ip4.nat_inside %}
(NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
({% trans "NAT for" %} <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }}">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
{% elif object.primary_ip4.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
({% trans "NAT" %}: {% for nat in object.primary_ip4.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip4" %}
{% else %}
@ -224,14 +225,14 @@
</td>
</tr>
<tr>
<th scope="row">Primary IPv6</th>
<th scope="row">{% trans "Primary IPv6" %}</th>
<td>
{% if object.primary_ip6 %}
<a href="{{ object.primary_ip6.get_absolute_url }}" id="primary_ip6">{{ object.primary_ip6.address.ip }}</a>
{% if object.primary_ip6.nat_inside %}
(NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
({% trans "NAT for" %} <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
{% elif object.primary_ip6.nat_outside.exists %}
(NAT: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
({% trans "NAT" %}: {% for nat in object.primary_ip6.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
{% endif %}
{% copy_content "primary_ip6" %}
{% else %}
@ -252,7 +253,7 @@
</tr>
{% if object.cluster %}
<tr>
<th>Cluster</th>
<th>{% trans "Cluster" %}</th>
<td>
{% if object.cluster.group %}
{{ object.cluster.group|linkify }} /
@ -267,25 +268,25 @@
{% if object.powerports.exists and object.poweroutlets.exists %}
<div class="card">
<h5 class="card-header">
Power Utilization
{% trans "Power Utilization" %}
</h5>
<div class="card-body">
<table class="table table-hover">
<tr>
<th>Input</th>
<th>Outlets</th>
<th>Allocated</th>
<th>Available</th>
<th>Utilization</th>
<th>{% trans "Input" %}</th>
<th>{% trans "Outlets" %}</th>
<th>{% trans "Allocated" %}</th>
<th>{% trans "Available" %}</th>
<th>{% trans "Utilization" %}</th>
</tr>
{% for powerport in object.powerports.all %}
{% with utilization=powerport.get_power_draw powerfeed=powerport.connected_endpoints.0 %}
<tr>
<td>{{ powerport }}</td>
<td>{{ utilization.outlet_count }}</td>
<td>{{ utilization.allocated }}VA</td>
<td>{{ utilization.allocated }}{% trans "VA" %}</td>
{% if powerfeed.available_power %}
<td>{{ powerfeed.available_power }}VA</td>
<td>{{ powerfeed.available_power }}{% trans "VA" %}</td>
<td>{% utilization_graph utilization.allocated|percentage:powerfeed.available_power %}</td>
{% else %}
<td class="text-muted">&mdash;</td>
@ -294,12 +295,12 @@
</tr>
{% for leg in utilization.legs %}
<tr>
<td style="padding-left: 20px">Leg {{ leg.name }}</td>
<td style="padding-left: 20px">{% blocktrans %}Leg {{ leg.name }}{% endblocktrans %}</td>
<td>{{ leg.outlet_count }}</td>
<td>{{ leg.allocated }}</td>
{% if powerfeed.available_power %}
{% with phase_available=powerfeed.available_power|divide:3 %}
<td>{{ phase_available }}VA</td>
<td>{{ phase_available }}{% trans "VA" %}</td>
<td>{% utilization_graph leg.allocated|percentage:phase_available %}</td>
{% endwith %}
{% else %}
@ -315,7 +316,7 @@
</div>
{% endif %}
<div class="card">
<h5 class="card-header">Services</h5>
<h5 class="card-header">{% trans "Services" %}</h5>
<div class="card-body htmx-container table-responsive"
hx-get="{% url 'ipam:service_list' %}?device_id={{ object.pk }}"
hx-trigger="load"
@ -323,27 +324,27 @@
{% if perms.ipam.add_service %}
<div class="card-footer text-end noprint">
<a href="{% url 'ipam:service_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add a service
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a service" %}
</a>
</div>
{% endif %}
</div>
{% include 'inc/panels/image_attachments.html' %}
<div class="card">
<h5 class="card-header">Dimensions</h5>
<h5 class="card-header">{% trans "Dimensions" %}</h5>
<div class="card-body table-responsive">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Height</th>
<th scope="row">{% trans "Height" %}</th>
<td>
{{ object.device_type.u_height }}U
</td>
</tr>
<tr>
<th scope="row">Weight</th>
<th scope="row">{% trans "Weight" %}</th>
<td>
{% if object.total_weight %}
{{ object.total_weight|floatformat }} Kilograms
{{ object.total_weight|floatformat }} {% trans "Kilograms" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
@ -356,13 +357,13 @@
<div class="row" style="margin-bottom: 20px">
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>Front</h4>
<h4>{% trans "Front" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='front' extra_params=svg_extra %}
</div>
</div>
<div class="col col-md-6 col-sm-6 col-xs-12 text-center">
<div style="margin-left: 30px">
<h4>Rear</h4>
<h4>{% trans "Rear" %}</h4>
{% include 'dcim/inc/rack_elevation.html' with object=object.rack face='rear' extra_params=svg_extra %}
</div>
</div>

View File

@ -3,6 +3,7 @@
{% load static %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
@ -17,38 +18,38 @@
{% if perms.dcim.change_device %}
<div class="dropdown">
<button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Components
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.dcim.add_consoleport %}
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}">{% trans "Console Ports" %}</a></li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}">{% trans "Console Server Ports" %}</a></li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li><a class="dropdown-item" href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}">Power Ports</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}">{% trans "Power Ports" %}</a></li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}">{% trans "Power Outlets" %}</a></li>
{% endif %}
{% if perms.dcim.add_interface %}
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}">{% trans "Interfaces" %}</a></li>
{% endif %}
{% if perms.dcim.add_frontport %}
<li><a class="dropdown-item" href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">Front Ports</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}">{% trans "Front Ports" %}</a></li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li><a class="dropdown-item" href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}">Module Bays</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li><a class="dropdown-item" href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}">{% trans "Device Bays" %}</a></li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}">Inventory Items</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}">{% trans "Inventory Items" %}</a></li>
{% endif %}
</ul>
</div>

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
@ -20,22 +21,22 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleport_bulk_rename' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:consoleport_bulk_delete' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleport_bulk_disconnect' %}?return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -43,7 +44,7 @@
{% if perms.dcim.add_consoleport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Console Port
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Port" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
@ -20,22 +21,22 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:consoleserverport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:consoleserverport_bulk_rename' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:consoleserverport_bulk_delete' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:consoleserverport_bulk_disconnect' %}?return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -43,7 +44,7 @@
{% if perms.dcim.add_consoleserverport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Console Server Ports
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
@ -20,23 +21,23 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
</div>
{% if perms.dcim.add_devicebay %}
<div class="bulk-button-group">
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Device Bays
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
@ -20,22 +21,22 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:frontport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:frontport_bulk_rename' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:frontport_bulk_delete' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:frontport_bulk_disconnect' %}?return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -43,7 +44,7 @@
{% if perms.dcim.add_frontport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add front ports
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add front ports" %}
</a>
</div>
{% endif %}

View File

@ -1,12 +1,13 @@
{% extends 'inc/table_controls_htmx.html' %}
{% load i18n %}
{% block extra_table_controls %}
<button class="btn btn-sm btn-outline-dark dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-eye"></i>
</button>
<ul class="dropdown-menu">
<button type="button" class="dropdown-item toggle-enabled" data-state="show">Hide Enabled</button>
<button type="button" class="dropdown-item toggle-disabled" data-state="show">Hide Disabled</button>
<button type="button" class="dropdown-item toggle-virtual" data-state="show">Hide Virtual</button>
<button type="button" class="dropdown-item toggle-enabled" data-state="show">{% trans "Hide Enabled" %}</button>
<button type="button" class="dropdown-item toggle-disabled" data-state="show">{% trans "Hide Disabled" %}</button>
<button type="button" class="dropdown-item toggle-virtual" data-state="show">{% trans "Hide Virtual" %}</button>
</ul>
{% endblock extra_table_controls %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
@ -22,12 +23,12 @@
<button type="submit" name="_edit"
formaction="{% url 'dcim:interface_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename"
formaction="{% url 'dcim:interface_bulk_rename' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
@ -36,14 +37,14 @@
<button type="submit" name="_delete"
formaction="{% url 'dcim:interface_bulk_delete' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect"
formaction="{% url 'dcim:interface_bulk_disconnect' %}?return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -52,7 +53,7 @@
<div class="bulk-button-group">
<a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Interfaces
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
@ -20,23 +21,23 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:inventoryitem_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:inventoryitem_bulk_rename' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:inventoryitem_bulk_delete' %}?return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
</div>
{% if perms.dcim.add_inventoryitem %}
<div class="bulk-button-group">
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Inventory Item
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %}
@ -20,23 +21,23 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:modulebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
</div>
{% if perms.dcim.add_modulebay %}
<div class="bulk-button-group">
<a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Module Bays
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
@ -20,22 +21,22 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:poweroutlet_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:poweroutlet_bulk_rename' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:poweroutlet_bulk_delete' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:poweroutlet_bulk_disconnect' %}?return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -43,7 +44,7 @@
{% if perms.dcim.add_poweroutlet %}
<div class="bulk-button-group">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Power Outlets
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load helpers %}
{% load static %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
@ -20,22 +21,22 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:powerport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:powerport_bulk_rename' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" name="_delete" formaction="{% url 'dcim:powerport_bulk_delete' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:powerport_bulk_disconnect' %}?return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -43,7 +44,7 @@
{% if perms.dcim.add_powerport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}" class="btn btn-sm btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Power Port
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
</a>
</div>
{% endif %}

View File

@ -2,6 +2,7 @@
{% load render_table from django_tables2 %}
{% load static %}
{% load helpers %}
{% load i18n %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
@ -20,22 +21,22 @@
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
<button type="submit" name="_edit" formaction="{% url 'dcim:rearport_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit" %}
</button>
<button type="submit" name="_rename" formaction="{% url 'dcim:rearport_bulk_rename' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
<div class="btn-group" role="group">
{% if 'bulk_delete' in actions %}
<button type="submit" formaction="{% url 'dcim:rearport_bulk_delete' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
</button>
{% endif %}
{% if 'bulk_edit' in actions %}
<button type="submit" name="_disconnect" formaction="{% url 'dcim:rearport_bulk_disconnect' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> Disconnect
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
</div>
@ -43,7 +44,7 @@
{% if perms.dcim.add_rearport %}
<div class="bulk-button-group">
<a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add rear ports
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add rear ports" %}
</a>
</div>
{% endif %}

View File

@ -1,25 +1,26 @@
{% extends 'dcim/device/base.html' %}
{% load static %}
{% load i18n %}
{% block title %}{{ object }} - Config{% endblock %}
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
{% block content %}
<div class="row mb-3">
<div class="col-5">
<div class="card">
<h5 class="card-header">Config Template</h5>
<h5 class="card-header">{% trans "Config Template" %}</h5>
<div class="card-body">
<table class="table table-hover attr-table">
<tr>
<th scope="row">Config Template</th>
<th scope="row">{% trans "Config Template" %}</th>
<td>{{ config_template|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Data Source</th>
<th scope="row">{% trans "Data Source" %}</th>
<td>{{ config_template.data_file.source|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">Data File</th>
<th scope="row">{% trans "Data File" %}</th>
<td>{{ config_template.data_file|linkify|placeholder }}</td>
</tr>
</table>
@ -33,7 +34,7 @@
<div class="accordion-item">
<h2 class="accordion-header" id="renderConfigHeading">
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
Context Data
{% trans "Context Data" %}
</button>
</h2>
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
@ -53,15 +54,15 @@
<div class="card-header">
<div class="float-end">
<a href="?export=True" class="btn btn-sm btn-primary" role="button">
<i class="mdi mdi-download" aria-hidden="true"></i> Download
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
</a>
</div>
<h5>Rendered Config</h5>
<h5>{% trans "Rendered Config" %}</h5>
</div>
{% if config_template %}
<pre class="card-body">{{ rendered_config }}</pre>
{% else %}
<div class="card-body text-muted">No configuration template found</div>
<div class="card-body text-muted">{% trans "No configuration template found" %}</div>
{% endif %}
</div>
</div>

View File

@ -1,12 +1,13 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% load i18n %}
{% block form %}
{% render_errors form %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Device</h5>
<h5 class="offset-sm-3">{% trans "Device" %}</h5>
</div>
{% render_field form.name %}
{% render_field form.device_role %}
@ -16,7 +17,7 @@
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Hardware</h5>
<h5 class="offset-sm-3">{% trans "Hardware" %}</h5>
</div>
{% render_field form.device_type %}
{% render_field form.airflow %}
@ -26,7 +27,7 @@
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Location</h5>
<h5 class="offset-sm-3">{% trans "Location" %}</h5>
</div>
{% render_field form.site %}
{% render_field form.location %}
@ -34,18 +35,18 @@
{% if object.device_type.is_child_device and object.parent_bay %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Device</label>
<label class="col-sm-3 col-form-label">{% trans "Parent Device" %}</label>
<div class="col">
<input class="form-control" value="{{ object.parent_bay.device }}" disabled />
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label">Parent Bay</label>
<label class="col-sm-3 col-form-label">{% trans "Parent Bay" %}</label>
<div class="col">
<div class="input-group">
<input class="form-control" value="{{ object.parent_bay.name }}" disabled />
<a href="{% url 'dcim:devicebay_depopulate' pk=object.parent_bay.pk %}" title="Regenerate Slug" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;Remove
<a href="{% url 'dcim:devicebay_depopulate' pk=object.parent_bay.pk %}" title="{% trans "Regenerate Slug" %}" class="btn btn-danger d-inline-flex align-items-center">
<i class="mdi mdi-close-thick"></i>&nbsp;{% trans "Remove" %}
</a>
</div>
</div>
@ -60,7 +61,7 @@
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Management</h5>
<h5 class="offset-sm-3">{% trans "Management" %}</h5>
</div>
{% render_field form.status %}
{% render_field form.platform %}
@ -74,14 +75,14 @@
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtualization</h5>
<h5 class="offset-sm-3">{% trans "Virtualization" %}</h5>
</div>
{% render_field form.cluster %}
</div>
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Tenancy</h5>
<h5 class="offset-sm-3">{% trans "Tenancy" %}</h5>
</div>
{% render_field form.tenant_group %}
{% render_field form.tenant %}
@ -89,7 +90,7 @@
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Virtual Chassis</h5>
<h5 class="offset-sm-3">{% trans "Virtual Chassis" %}</h5>
</div>
{% render_field form.virtual_chassis %}
{% render_field form.vc_position %}
@ -99,14 +100,14 @@
{% if form.custom_fields %}
<div class="field-group my-5">
<div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5>
<h5 class="offset-sm-3">{% trans "Custom Fields" %}</h5>
</div>
{% render_custom_fields form %}
</div>
{% endif %}
<div class="field-group my-5">
<h5 class="text-center">Local Config Context Data</h5>
<h5 class="text-center">{% trans "Local Config Context Data" %}</h5>
{% render_field form.local_context_data %}
</div>

View File

@ -1,73 +1,74 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.dcim.change_device %}
<div class="dropdown">
<button type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Components
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Console Ports
{% trans "Console Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
Console Server Ports
{% trans "Console Server Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Power Ports
{% trans "Power Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Power Outlets
{% trans "Power Outlets" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}"
class="dropdown-item">Interfaces
class="dropdown-item">{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Rear Ports
{% trans "Rear Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Device Bays
{% trans "Device Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_modulebay %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Module Bays
{% trans "Module Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li>
<button type="submit" formaction="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
Inventory Items
{% trans "Inventory Items" %}
</button>
</li>
{% endif %}
@ -78,7 +79,7 @@
<div class="btn-group" role="group">
{% 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 btn-sm">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}

Some files were not shown because too many files have changed in this diff Show More