Merge branch 'develop' into 15496-circuit-termination

This commit is contained in:
Arthur 2024-05-09 10:27:00 -07:00
commit 6b8a6daeee
40 changed files with 164 additions and 129 deletions

View File

@ -34,10 +34,9 @@ body:
label: Python Version label: Python Version
description: What version of Python are you currently running? description: What version of Python are you currently running?
options: options:
- "3.8"
- "3.9"
- "3.10" - "3.10"
- "3.11" - "3.11"
- "3.12"
validations: validations:
required: true required: true
- type: textarea - type: textarea

View File

@ -11,8 +11,24 @@ master = true
; clear environment on exit ; clear environment on exit
vacuum = true vacuum = true
; make SIGTERM stop the app (instead of reload)
die-on-term = true
; exit if no app can be loaded ; exit if no app can be loaded
need-app = true need-app = true
; do not use multiple interpreters ; do not use multiple interpreters
single-interpreter = true single-interpreter = true
; change to the project directory
chdir = netbox
; specify the WSGI module to load
module = netbox.wsgi
; workaround to make uWSGI reloads work with pyuwsgi (not to be used if using uwsgi package instead)
binary-path = venv/bin/python
; only log internal messages and errors (reverse proxy already logs the requests)
disable-logging = true
log-5xx = true

View File

@ -77,7 +77,7 @@ Create the following for each model:
## 13. GraphQL API components ## 13. GraphQL API components
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.

View File

@ -17,7 +17,7 @@ pip3 install pyuwsgi
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment: Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
```no-highlight ```no-highlight
sudo sh -c "echo 'pyuwgsi' >> /opt/netbox/local_requirements.txt" sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt"
``` ```
## Configuration ## Configuration

View File

@ -1,6 +1,6 @@
# GraphQL API Overview # GraphQL API Overview
NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by the [Graphene](https://graphene-python.org/) library and [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/). NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry-graphql.github.io/strawberry-django/).
## Queries ## Queries
@ -47,7 +47,7 @@ NetBox provides both a singular and plural query field for each object type:
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices. For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/) as well as the [GraphQL queries documentation](https://graphql.org/learn/queries/). For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry-graphql.github.io/strawberry-django/guide/filters/).
## Filtering ## Filtering

View File

@ -2,7 +2,7 @@
## Defining the Schema Class ## Defining the Schema Class
A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`. A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class.
### Example ### Example

View File

@ -48,7 +48,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
class CircuitSerializer(NetBoxModelSerializer): class CircuitSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
provider = ProviderSerializer(nested=True) provider = ProviderSerializer(nested=True)
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True) provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
status = ChoiceField(choices=CircuitStatusChoices, required=False) status = ChoiceField(choices=CircuitStatusChoices, required=False)
type = CircuitTypeSerializer(nested=True) type = CircuitTypeSerializer(nested=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)

View File

@ -45,6 +45,7 @@ class ProviderSerializer(NetBoxModelSerializer):
class ProviderAccountSerializer(NetBoxModelSerializer): class ProviderAccountSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
provider = ProviderSerializer(nested=True) provider = ProviderSerializer(nested=True)
name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
class Meta: class Meta:
model = ProviderAccount model = ProviderAccount

View File

@ -141,7 +141,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
{ {
'cid': 'Circuit 6', 'cid': 'Circuit 6',
'provider': providers[1].pk, 'provider': providers[1].pk,
'provider_account': provider_accounts[1].pk, # Omit provider account to test uniqueness constraint
'type': circuit_types[1].pk, 'type': circuit_types[1].pk,
}, },
] ]
@ -237,7 +237,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
'account': '5678', 'account': '5678',
}, },
{ {
'name': 'Provider Account 6', # Omit name to test uniqueness constraint
'provider': providers[0].pk, 'provider': providers[0].pk,
'account': '6789', 'account': '6789',
}, },

View File

@ -122,6 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
class VirtualDeviceContextSerializer(NetBoxModelSerializer): class VirtualDeviceContextSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
device = DeviceSerializer(nested=True) device = DeviceSerializer(nested=True)
identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None) tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)

View File

@ -51,7 +51,7 @@ class SiteSerializer(NetBoxModelSerializer):
status = ChoiceField(choices=SiteStatusChoices, required=False) status = ChoiceField(choices=SiteStatusChoices, required=False)
region = RegionSerializer(nested=True, required=False, allow_null=True) region = RegionSerializer(nested=True, required=False, allow_null=True)
group = SiteGroupSerializer(nested=True, required=False, allow_null=True) group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
time_zone = TimeZoneSerializerField(required=False, allow_null=True) time_zone = TimeZoneSerializerField(required=False, allow_null=True)
asns = SerializedPKRelatedField( asns = SerializedPKRelatedField(
queryset=ASN.objects.all(), queryset=ASN.objects.all(),
@ -83,7 +83,7 @@ class SiteSerializer(NetBoxModelSerializer):
class LocationSerializer(NestedGroupModelSerializer): class LocationSerializer(NestedGroupModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
site = SiteSerializer(nested=True) site = SiteSerializer(nested=True)
parent = NestedLocationSerializer(required=False, allow_null=True) parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
status = ChoiceField(choices=LocationStatusChoices, required=False) status = ChoiceField(choices=LocationStatusChoices, required=False)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True)
rack_count = serializers.IntegerField(read_only=True) rack_count = serializers.IntegerField(read_only=True)

View File

@ -10,6 +10,7 @@ from dcim.models import *
from extras.models import ConfigTemplate from extras.models import ConfigTemplate
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices from wireless.choices import WirelessChannelChoices
@ -152,6 +153,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
rir = RIR.objects.create(name='RFC 6996', is_private=True) rir = RIR.objects.create(name='RFC 6996', is_private=True)
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
asns = [ asns = [
ASN(asn=65000 + i, rir=rir) for i in range(8) ASN(asn=65000 + i, rir=rir) for i in range(8)
@ -166,6 +168,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
'group': groups[1].pk, 'group': groups[1].pk,
'status': SiteStatusChoices.STATUS_ACTIVE, 'status': SiteStatusChoices.STATUS_ACTIVE,
'asns': [asns[0].pk, asns[1].pk], 'asns': [asns[0].pk, asns[1].pk],
'tenant': tenant.pk,
}, },
{ {
'name': 'Site 5', 'name': 'Site 5',
@ -230,7 +233,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
'name': 'Test Location 6', 'name': 'Test Location 6',
'slug': 'test-location-6', 'slug': 'test-location-6',
'site': sites[1].pk, 'site': sites[1].pk,
'parent': parent_locations[1].pk, # Omit parent to test uniqueness constraint
'status': LocationStatusChoices.STATUS_PLANNED, 'status': LocationStatusChoices.STATUS_PLANNED,
}, },
] ]
@ -2307,6 +2310,6 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
'device': devices[1].pk, 'device': devices[1].pk,
'status': 'active', 'status': 'active',
'name': 'VDC 3', 'name': 'VDC 3',
'identifier': 3, # Omit identifier to test uniqueness constraint
}, },
] ]

View File

@ -85,6 +85,7 @@ class Command(BaseCommand):
module_name, script_name = script.split('.', 1) module_name, script_name = script.split('.', 1)
module, script = get_module_and_script(module_name, script_name) module, script = get_module_and_script(module_name, script_name)
script = script.python_class
# Take user from command line if provided and exists, other # Take user from command line if provided and exists, other
if options['user']: if options['user']:

View File

@ -60,7 +60,10 @@ def get_module_scripts(scriptmodule):
return cls.full_name.split(".", maxsplit=1)[1] return cls.full_name.split(".", maxsplit=1)[1]
loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule)) loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule))
try:
module = loader.load_module() module = loader.load_module()
except FileNotFoundError:
return {}
scripts = {} scripts = {}
ordered = getattr(module, 'script_order', []) ordered = getattr(module, 'script_order', [])

View File

@ -545,7 +545,7 @@ class ScriptResultsTable(BaseTable):
template_code="""{% load log_levels %}{% log_level record.status %}""", template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level') verbose_name=_('Level')
) )
message = tables.Column( message = columns.MarkdownColumn(
verbose_name=_('Message') verbose_name=_('Message')
) )
@ -566,22 +566,17 @@ class ReportResultsTable(BaseTable):
time = tables.Column( time = tables.Column(
verbose_name=_('Time') verbose_name=_('Time')
) )
status = tables.Column(
empty_values=(),
verbose_name=_('Level')
)
status = tables.TemplateColumn( status = tables.TemplateColumn(
template_code="""{% load log_levels %}{% log_level record.status %}""", template_code="""{% load log_levels %}{% log_level record.status %}""",
verbose_name=_('Level') verbose_name=_('Level')
) )
object = tables.Column( object = tables.Column(
verbose_name=_('Object') verbose_name=_('Object')
) )
url = tables.Column( url = tables.Column(
verbose_name=_('URL') verbose_name=_('URL')
) )
message = tables.Column( message = columns.MarkdownColumn(
verbose_name=_('Message') verbose_name=_('Message')
) )

View File

@ -574,7 +574,7 @@ class IPRange(PrimaryModel):
if not self.end_address > self.start_address: if not self.end_address > self.start_address:
raise ValidationError({ raise ValidationError({
'end_address': _( 'end_address': _(
"Ending address must be lower than the starting address ({start_address})" "Ending address must be greater than the starting address ({start_address})"
).format(start_address=self.start_address) ).format(start_address=self.start_address)
}) })

View File

@ -781,6 +781,7 @@ class IPAddressView(generic.ObjectView):
class IPAddressEditView(generic.ObjectEditView): class IPAddressEditView(generic.ObjectEditView):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
form = forms.IPAddressForm form = forms.IPAddressForm
template_name = 'ipam/ipaddress_edit.html'
def alter_object(self, obj, request, url_args, url_kwargs): def alter_object(self, obj, request, url_args, url_kwargs):

View File

@ -32,6 +32,7 @@ class MenuItem:
link: str link: str
link_text: str link_text: str
permissions: Optional[Sequence[str]] = () permissions: Optional[Sequence[str]] = ()
auth_required: Optional[bool] = False
staff_only: Optional[bool] = False staff_only: Optional[bool] = False
buttons: Optional[Sequence[MenuItemButton]] = () buttons: Optional[Sequence[MenuItemButton]] = ()

View File

@ -372,6 +372,7 @@ ADMIN_MENU = Menu(
MenuItem( MenuItem(
link=f'users:user_list', link=f'users:user_list',
link_text=_('Users'), link_text=_('Users'),
auth_required=True,
permissions=[f'auth.view_user'], permissions=[f'auth.view_user'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
@ -391,6 +392,7 @@ ADMIN_MENU = Menu(
MenuItem( MenuItem(
link=f'users:group_list', link=f'users:group_list',
link_text=_('Groups'), link_text=_('Groups'),
auth_required=True,
permissions=[f'auth.view_group'], permissions=[f'auth.view_group'],
buttons=( buttons=(
MenuItemButton( MenuItemButton(
@ -410,12 +412,14 @@ ADMIN_MENU = Menu(
MenuItem( MenuItem(
link=f'users:token_list', link=f'users:token_list',
link_text=_('API Tokens'), link_text=_('API Tokens'),
auth_required=True,
permissions=[f'users.view_token'], permissions=[f'users.view_token'],
buttons=get_model_buttons('users', 'token') buttons=get_model_buttons('users', 'token')
), ),
MenuItem( MenuItem(
link=f'users:objectpermission_list', link=f'users:objectpermission_list',
link_text=_('Permissions'), link_text=_('Permissions'),
auth_required=True,
permissions=[f'users.view_objectpermission'], permissions=[f'users.view_objectpermission'],
buttons=get_model_buttons('users', 'objectpermission', actions=['add']) buttons=get_model_buttons('users', 'objectpermission', actions=['add'])
), ),
@ -426,16 +430,19 @@ ADMIN_MENU = Menu(
items=( items=(
MenuItem( MenuItem(
link='core:system', link='core:system',
link_text=_('System') link_text=_('System'),
auth_required=True
), ),
MenuItem( MenuItem(
link='core:configrevision_list', link='core:configrevision_list',
link_text=_('Configuration History'), link_text=_('Configuration History'),
auth_required=True,
permissions=['core.view_configrevision'] permissions=['core.view_configrevision']
), ),
MenuItem( MenuItem(
link='core:background_queue_list', link='core:background_queue_list',
link_text=_('Background Tasks') link_text=_('Background Tasks'),
auth_required=True
), ),
), ),
), ),

View File

@ -372,7 +372,6 @@ if not DJANGO_ADMIN_ENABLED:
# Middleware # Middleware
MIDDLEWARE = [ MIDDLEWARE = [
"strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware", "strawberry_django.middlewares.debug_toolbar.DebugToolbarMiddleware",
'django_prometheus.middleware.PrometheusBeforeMiddleware',
'corsheaders.middleware.CorsMiddleware', 'corsheaders.middleware.CorsMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.locale.LocaleMiddleware', 'django.middleware.locale.LocaleMiddleware',
@ -386,8 +385,14 @@ MIDDLEWARE = [
'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.RemoteUserMiddleware',
'netbox.middleware.CoreMiddleware', 'netbox.middleware.CoreMiddleware',
'netbox.middleware.MaintenanceModeMiddleware', 'netbox.middleware.MaintenanceModeMiddleware',
'django_prometheus.middleware.PrometheusAfterMiddleware',
] ]
if METRICS_ENABLED:
# If metrics are enabled, add the before & after Prometheus middleware
MIDDLEWARE = [
'django_prometheus.middleware.PrometheusBeforeMiddleware',
*MIDDLEWARE,
'django_prometheus.middleware.PrometheusAfterMiddleware',
]
# URLs # URLs
ROOT_URLCONF = 'netbox.urls' ROOT_URLCONF = 'netbox.urls'
@ -522,7 +527,6 @@ if SENTRY_ENABLED:
sentry_sdk.init( sentry_sdk.init(
dsn=SENTRY_DSN, dsn=SENTRY_DSN,
release=VERSION, release=VERSION,
integrations=[sentry_sdk.integrations.django.DjangoIntegration()],
sample_rate=SENTRY_SAMPLE_RATE, sample_rate=SENTRY_SAMPLE_RATE,
traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE,
send_default_pii=True, send_default_pii=True,

View File

@ -52,7 +52,7 @@ class BaseTable(tables.Table):
# Set default empty_text if none was provided # Set default empty_text if none was provided
if self.empty_text is None: if self.empty_text is None:
self.empty_text = f"No {self._meta.model._meta.verbose_name_plural} found" self.empty_text = _("No {model_name} found").format(model_name=self._meta.model._meta.verbose_name_plural)
# Determine the table columns to display by checking the following: # Determine the table columns to display by checking the following:
# 1. User's configuration for the table # 1. User's configuration for the table

Binary file not shown.

Binary file not shown.

View File

@ -10,9 +10,9 @@ function quickSearchEventHandler(event: Event): void {
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement; const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
if (isTruthy(clearbtn)) { if (isTruthy(clearbtn)) {
if (quicksearch.value === "") { if (quicksearch.value === "") {
clearbtn.classList.add("d-none"); clearbtn.classList.add("invisible");
} else { } else {
clearbtn.classList.remove("d-none"); clearbtn.classList.remove("invisible");
} }
} }
} }

View File

@ -20,7 +20,7 @@
{# Initialize color mode #} {# Initialize color mode #}
<script <script
type="text/javascript" type="text/javascript"
src="{% static 'setmode.js' %}" src="{% static 'setmode.js' %}?v={{ settings.VERSION }}"
onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'"> onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
</script> </script>
<script type="text/javascript"> <script type="text/javascript">

View File

@ -31,6 +31,10 @@
<th scope="row">{% trans "NetBox version" %}</th> <th scope="row">{% trans "NetBox version" %}</th>
<td>{{ stats.netbox_version }}</td> <td>{{ stats.netbox_version }}</td>
</tr> </tr>
<tr>
<th scope="row">{% trans "Python version" %}</th>
<td>{{ stats.python_version }}</td>
</tr>
<tr> <tr>
<th scope="row">{% trans "Django version" %}</th> <th scope="row">{% trans "Django version" %}</th>
<td>{{ stats.django_version }}</td> <td>{{ stats.django_version }}</td>

View File

@ -77,15 +77,9 @@
</div> </div>
</div> </div>
<div class="col col-md-7"> <div class="col col-md-7">
<div class="card">
<h5 class="card-header d-flex justify-content-between">
{% trans "Data" %}
{% include 'extras/inc/configcontext_format.html' %}
</h5>
<div class="card-body">
{% include 'inc/sync_warning.html' %} {% include 'inc/sync_warning.html' %}
{% include 'extras/inc/configcontext_data.html' with data=object.data format=format %} <div class="card">
</div> {% include 'extras/inc/configcontext_data.html' with title="Data" data=object.data format=format copyid="data" %}
</div> </div>
</div> </div>
</div> </div>

View File

@ -1,5 +1,17 @@
{% load helpers %} {% load helpers %}
{% load i18n %}
<div class="rendered-context-data mt-1"> {% if title %}
<pre class="block">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre> <h5 class="card-header d-flex justify-content-between">
{% trans title %}
<div>
{% if copyid %}{% copy_content copyid %}{% endif %}
{% include 'extras/inc/format_toggle.html' %}
</div>
</h5>
{% endif %}
<div class="card-body">
<div class="rendered-context-data mt-1">
<pre class="block" {% if copyid %}id="{{ copyid }}{% endif %}">{% if format == 'json' %}{{ data|json }}{% elif format == 'yaml' %}{{ data|yaml }}{% else %}{{ data }}{% endif %}</pre>
</div>
</div> </div>

View File

@ -1,6 +0,0 @@
<div>
<div class="btn-group btn-group-sm" role="group">
<a href="?format=json" type="button" class="btn btn-outline-dark{% if format == 'json' %} active{% endif %}">JSON</a>
<a href="?format=yaml" type="button" class="btn btn-outline-dark{% if format == 'yaml' %} active{% endif %}">YAML</a>
</div>
</div>

View File

@ -0,0 +1,4 @@
<div class="btn-group btn-group-sm" role="group">
<a href="?format=json" type="button" class="btn btn-outline-dark{% if format == 'json' %} active{% endif %}">JSON</a>
<a href="?format=yaml" type="button" class="btn btn-outline-dark{% if format == 'yaml' %} active{% endif %}">YAML</a>
</div>

View File

@ -7,27 +7,12 @@
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header d-flex justify-content-between"> {% include 'extras/inc/configcontext_data.html' with title="Rendered Context" data=rendered_context format=format copyid="rendered_context" %}
{% trans "Rendered Context" %}
{% include 'extras/inc/configcontext_format.html' %}
</h5>
<div class="card-body">
{% include 'extras/inc/configcontext_data.html' with data=rendered_context format=format %}
</div>
</div> </div>
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> {% include 'extras/inc/configcontext_data.html' with title="Local Context" data=object.local_context_data format=format copyid="local_context" %}
{% trans "Local Context" %}
</h5>
<div class="card-body">
{% if object.local_context_data %}
{% include 'extras/inc/configcontext_data.html' with data=object.local_context_data format=format %}
{% else %}
<span class="text-muted">{% trans "None" %}</span>
{% endif %}
</div>
<div class="card-footer"> <div class="card-footer">
<span class="help-block"> <span class="help-block">
<i class="mdi mdi-information-outline"></i> <i class="mdi mdi-information-outline"></i>
@ -36,8 +21,11 @@
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header d-flex justify-content-between">
{% trans "Source Contexts" %} {% trans "Source Contexts" %}
<div>
{% include 'extras/inc/format_toggle.html' %}
</div>
</h5> </h5>
{% for context in source_contexts %} {% for context in source_contexts %}
<div class="card-body"> <div class="card-body">

View File

@ -7,7 +7,7 @@
<input type="search" results="5" name="q" id="quicksearch" class="form-control px-2 py-1" placeholder="Quick search" <input type="search" results="5" name="q" id="quicksearch" class="form-control px-2 py-1" placeholder="Quick search"
hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search" /> hx-get="{{ request.full_path }}" hx-target="#object_list" hx-trigger="keyup changed delay:500ms, search" />
<span class="input-group-text py-1"> <span class="input-group-text py-1">
<a href="#" id="quicksearch_clear" class="d-none text-secondary"><i class="mdi mdi-close-circle"></i></a> <a href="#" id="quicksearch_clear" class="invisible text-secondary"><i class="mdi mdi-close-circle"></i></a>
</span> </span>
{% block extra_table_controls %}{% endblock %} {% block extra_table_controls %}{% endblock %}
</div> </div>

View File

@ -3,28 +3,19 @@
<ul class="nav nav-tabs"> <ul class="nav nav-tabs">
<li class="nav-item"> <li class="nav-item">
<a <a href="{% url 'ipam:ipaddress_add' %}{% querystring request %}" class="nav-link {% if active_tab == 'add' %}active{% endif %}">
class="nav-link {% if active_tab == 'add' %}active{% endif %}" {% if object.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}
href="{% url 'ipam:ipaddress_add' %}{% querystring request %}"
>
{% if obj.pk %}{% trans "Edit" %}{% else %}{% trans "Create" %}{% endif %}
</a> </a>
</li> </li>
{% if 'interface' in request.GET or 'vminterface' in request.GET %} {% if 'interface' in request.GET or 'vminterface' in request.GET %}
<li class="nav-item"> <li class="nav-item">
<a <a href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}" class="nav-link {% if active_tab == 'assign' %}active{% endif %}">
class="nav-link {% if active_tab == 'assign' %}active{% endif %}"
href="{% url 'ipam:ipaddress_assign' %}{% querystring request %}"
>
{% trans "Assign IP" %} {% trans "Assign IP" %}
</a> </a>
</li> </li>
{% else %} {% elif not object.pk %}
<li class="nav-item"> <li class="nav-item">
<a <a href="{% url 'ipam:ipaddress_bulk_add' %}{% querystring request %}" class="nav-link {% if active_tab == 'bulk_add' %}active{% endif %}">
class="nav-link {% if active_tab == 'bulk_add' %}active{% endif %}"
href="{% url 'ipam:ipaddress_bulk_add' %}{% querystring request %}"
>
{% trans "Bulk Create" %} {% trans "Bulk Create" %}
</a> </a>
</li> </li>

View File

@ -17,21 +17,17 @@
{% for field in form.hidden_fields %} {% for field in form.hidden_fields %}
{{ field }} {{ field }}
{% endfor %} {% endfor %}
<div class="row mb-3"> <div class="field-group my-5">
<div class="col col-md-8 offset-md-2"> <div class="row">
<div class="field-group"> <h5 class="col-9 offset-3">{% trans "Select IP Address" %}</h5>
<h6>{% trans "Select IP Address" %}</h6> </div>
{% render_field form.vrf_id %} {% render_field form.vrf_id %}
{% render_field form.q %} {% render_field form.q %}
</div> </div>
</div> <div class="text-end my-3">
</div>
<div class="row mb-3">
<div class="col col-md-8 offset-md-2 text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a> <a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" class="btn btn-primary">{% trans "Search" %}</button> <button type="submit" class="btn btn-primary">{% trans "Search" %}</button>
</div> </div>
</div>
</form> </form>
{% if table %} {% if table %}
<div class="row mb-3"> <div class="row mb-3">

View File

@ -0,0 +1,5 @@
{% extends 'generic/object_edit.html' %}
{% block tabs %}
{% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='add' %}
{% endblock %}

View File

@ -46,7 +46,21 @@
<input type="hidden" name="next" value="{{ request.POST.next }}" /> <input type="hidden" name="next" value="{{ request.POST.next }}" />
{% endif %} {% endif %}
{% render_form form %} <div class="form-group mb-3">
<label for="id_username" class="form-label">{{ form.username.label }}</label>
{{ form.username }}
{% for error in form.username.errors %}
<div class="alert alert-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="form-group">
<label for="id_password" class="form-label">{{ form.password.label }}</label>
{{ form.password }}
{% for error in form.password.errors %}
<div class="alert alert-danger">{{ error }}</div>
{% endfor %}
</div>
<div class="form-footer"> <div class="form-footer">
<button type="submit" class="btn btn-primary w-100"> <button type="submit" class="btn btn-primary w-100">

View File

@ -27,7 +27,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
class TenantSerializer(NetBoxModelSerializer): class TenantSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail') url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
group = TenantGroupSerializer(nested=True, required=False, allow_null=True) group = TenantGroupSerializer(nested=True, required=False, allow_null=True, default=None)
# Related object counts # Related object counts
circuit_count = RelatedObjectCountField('circuits') circuit_count = RelatedObjectCountField('circuits')

View File

@ -26,6 +26,8 @@ def nav(context):
for group in menu.groups: for group in menu.groups:
items = [] items = []
for item in group.items: for item in group.items:
if getattr(item, 'auth_required', False) and not user.is_authenticated:
continue
if not user.has_perms(item.permissions): if not user.has_perms(item.permissions):
continue continue
if item.staff_only and not user.is_staff: if item.staff_only and not user.is_staff:

View File

@ -31,11 +31,11 @@ __all__ = (
class VirtualMachineSerializer(NetBoxModelSerializer): class VirtualMachineSerializer(NetBoxModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail') url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False) status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
site = SiteSerializer(nested=True, required=False, allow_null=True) site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
cluster = ClusterSerializer(nested=True, required=False, allow_null=True) cluster = ClusterSerializer(nested=True, required=False, allow_null=True, default=None)
device = DeviceSerializer(nested=True, required=False, allow_null=True) device = DeviceSerializer(nested=True, required=False, allow_null=True, default=None)
role = DeviceRoleSerializer(nested=True, required=False, allow_null=True) role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
tenant = TenantSerializer(nested=True, required=False, allow_null=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
platform = PlatformSerializer(nested=True, required=False, allow_null=True) platform = PlatformSerializer(nested=True, required=False, allow_null=True)
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True) primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True) primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
@ -55,7 +55,6 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
'interface_count', 'virtual_disk_count', 'interface_count', 'virtual_disk_count',
] ]
brief_fields = ('id', 'url', 'display', 'name', 'description') brief_fields = ('id', 'url', 'display', 'name', 'description')
validators = []
class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):

View File

@ -1,4 +1,4 @@
Django==5.0.5 Django==5.0.6
django-cors-headers==4.3.1 django-cors-headers==4.3.1
django-debug-toolbar==4.3.0 django-debug-toolbar==4.3.0
django-filter==24.2 django-filter==24.2