mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-16 04:02:52 -06:00
Compare commits
23 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
b88b5b0b1b | ||
![]() |
6eeb382512 | ||
![]() |
e5d6c71171 | ||
![]() |
f777bfee2e | ||
![]() |
8b63eb64c1 | ||
![]() |
cff29f9551 | ||
![]() |
a5c0cae112 | ||
![]() |
2a27e475e4 | ||
![]() |
44efa037cc | ||
![]() |
6c17629159 | ||
![]() |
f13d028c98 | ||
![]() |
f5d32b1bf1 | ||
![]() |
f05897d61a | ||
![]() |
b5421f1cd6 | ||
![]() |
23cc4f1c41 | ||
![]() |
9c2cd66162 | ||
![]() |
f61a2964c8 | ||
![]() |
ee94fb0b94 | ||
![]() |
8fb8f4c75b | ||
![]() |
e33793dc82 | ||
![]() |
3b8841ee3b | ||
![]() |
ea4c205a37 | ||
![]() |
2a5d3abafb |
@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.3
|
||||
placeholder: v4.3.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.3
|
||||
placeholder: v4.3.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -6,7 +6,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||
<p>
|
||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
||||
|
@ -14,6 +14,10 @@ django-debug-toolbar
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
django-filter
|
||||
|
||||
# Django Debug Toolbar extension for GraphiQL
|
||||
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# HTMX utilities for Django
|
||||
# https://django-htmx.readthedocs.io/en/latest/changelog.html
|
||||
django-htmx
|
||||
@ -108,6 +112,7 @@ nh3
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
# https://github.com/python-pillow/Pillow/releases
|
||||
# https://pillow.readthedocs.io/en/stable/releasenotes/
|
||||
Pillow
|
||||
|
||||
# PostgreSQL database adapter for Python
|
||||
@ -126,14 +131,14 @@ requests
|
||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||
rq
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||
social-auth-app-django
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
||||
# Strawberry GraphQL
|
||||
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
||||
strawberry-graphql
|
||||
|
@ -158,6 +158,7 @@ LOGGING = {
|
||||
* `netbox.<app>.<model>` - Generic form for model-specific log messages
|
||||
* `netbox.auth.*` - Authentication events
|
||||
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
||||
* `netbox.event_rules` - Event rules
|
||||
* `netbox.reports.*` - Report execution (`module.name`)
|
||||
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
||||
* `netbox.views.*` - Views which handle business logic for the web UI
|
||||
|
@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
|
||||
|
||||
## Populating Demo Data
|
||||
|
||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
|
||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
|
||||
|
||||
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
||||
|
||||
|
@ -2,9 +2,9 @@
|
||||
|
||||
NetBox includes the ability to execute certain functions as background tasks. These include:
|
||||
|
||||
* [Report](../customization/reports.md) execution
|
||||
* [Custom script](../customization/custom-scripts.md) execution
|
||||
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
|
||||
* Housekeeping tasks
|
||||
|
||||
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
|
||||
|
||||
|
@ -135,7 +135,7 @@ Check out the desired release by specifying its tag. For example:
|
||||
|
||||
```
|
||||
cd /opt/netbox && \
|
||||
sudo git fetch && \
|
||||
sudo git fetch --tags && \
|
||||
sudo git checkout v4.2.7
|
||||
```
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for
|
||||
```python title="jobs.py"
|
||||
from netbox.jobs import JobRunner
|
||||
|
||||
|
||||
class MyTestJob(JobRunner):
|
||||
class Meta:
|
||||
name = "My Test Job"
|
||||
@ -25,6 +24,8 @@ class MyTestJob(JobRunner):
|
||||
# your logic goes here
|
||||
```
|
||||
|
||||
Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.)
|
||||
|
||||
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
|
||||
|
||||
!!! tip
|
||||
|
@ -1,5 +1,27 @@
|
||||
# NetBox v4.3
|
||||
|
||||
## v4.3.4 (2025-07-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
|
||||
* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
|
||||
* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
|
||||
* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
|
||||
* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
|
||||
* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
|
||||
* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
|
||||
* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
|
||||
* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
|
||||
* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
|
||||
* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
|
||||
|
||||
---
|
||||
|
||||
## v4.3.3 (2025-06-26)
|
||||
|
||||
### Enhancements
|
||||
|
@ -1,9 +1,19 @@
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
pass
|
||||
__all__ = (
|
||||
'IncompatiblePluginError',
|
||||
'JobFailed',
|
||||
'SyncError',
|
||||
)
|
||||
|
||||
|
||||
class IncompatiblePluginError(ImproperlyConfigured):
|
||||
pass
|
||||
|
||||
|
||||
class JobFailed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
pass
|
||||
|
@ -187,15 +187,14 @@ class Job(models.Model):
|
||||
"""
|
||||
Mark the job as completed, optionally specifying a particular termination status.
|
||||
"""
|
||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
raise ValueError(
|
||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||
choices=', '.join(valid_statuses)
|
||||
choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES)
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the job as completed
|
||||
# Set the job's status and completion time
|
||||
self.status = status
|
||||
if error:
|
||||
self.error = error
|
||||
|
@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
|
||||
'tags',
|
||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||
'comments', 'tags'
|
||||
]
|
||||
|
||||
|
||||
|
@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
||||
from tenancy.graphql.types import TenantType
|
||||
from users.graphql.types import UserType
|
||||
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
|
||||
from vpn.graphql.types import L2VPNTerminationType
|
||||
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
||||
|
||||
__all__ = (
|
||||
@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
|
||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
|
||||
|
||||
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
|
||||
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
||||
|
@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor):
|
||||
'gpu',
|
||||
'hard_disk',
|
||||
'memory',
|
||||
'power_supply'
|
||||
'power_supply',
|
||||
'expansion_card'
|
||||
)
|
||||
|
||||
for name in initial_profiles:
|
||||
|
44
netbox/dcim/migrations/0208_devicerole_uniqueness.py
Normal file
44
netbox/dcim/migrations/0208_devicerole_uniqueness.py
Normal file
@ -0,0 +1,44 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0207_remove_redundant_indexes'),
|
||||
('extras', '0129_fix_script_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('parent', 'name'),
|
||||
name='dcim_devicerole_parent_name'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('parent__isnull', True)),
|
||||
fields=('name',),
|
||||
name='dcim_devicerole_name',
|
||||
violation_error_message='A top-level device role with this name already exists.'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('parent', 'slug'),
|
||||
name='dcim_devicerole_parent_slug'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('parent__isnull', True)),
|
||||
fields=('slug',),
|
||||
name='dcim_devicerole_slug',
|
||||
violation_error_message='A top-level device role with this slug already exists.'
|
||||
),
|
||||
),
|
||||
]
|
@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Expansion card",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"connector_type": {
|
||||
"type": "string",
|
||||
"description": "Connector type e.g. PCIe x4"
|
||||
},
|
||||
"bandwidth": {
|
||||
"type": "integer",
|
||||
"description": "Total Bandwidth for this module"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'name'),
|
||||
name='%(app_label)s_%(class)s_parent_name'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('name',),
|
||||
name='%(app_label)s_%(class)s_name',
|
||||
condition=Q(parent__isnull=True),
|
||||
violation_error_message=_("A top-level device role with this name already exists.")
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'slug'),
|
||||
name='%(app_label)s_%(class)s_parent_slug'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('slug',),
|
||||
name='%(app_label)s_%(class)s_slug',
|
||||
condition=Q(parent__isnull=True),
|
||||
violation_error_message=_("A top-level device role with this slug already exists.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('device role')
|
||||
verbose_name_plural = _('device roles')
|
||||
|
||||
|
@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
device_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'role_id': 'pk'},
|
||||
@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.DeviceRole
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
|
||||
'slug', 'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template',
|
||||
'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
||||
|
||||
|
@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'region_id': 'pk'},
|
||||
@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Region
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||
@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = SiteGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||
@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
site = tables.Column(
|
||||
verbose_name=_('Site'),
|
||||
linkify=True
|
||||
@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
||||
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count',
|
||||
'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'vlangroup_count',
|
||||
)
|
||||
default_columns = (
|
||||
|
@ -3,7 +3,7 @@ from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
from django.test import override_settings
|
||||
from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
@ -1000,18 +1000,7 @@ inventory-items:
|
||||
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
||||
|
||||
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by absence of bulk import view for ModuleTypes
|
||||
class ModuleTypeTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||
ViewTestCases.CreateObjectViewTestCase,
|
||||
ViewTestCases.EditObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = ModuleType
|
||||
|
||||
@classmethod
|
||||
@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
|
||||
)
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
ModuleType.objects.bulk_create([
|
||||
module_types = ModuleType.objects.bulk_create([
|
||||
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
|
||||
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
|
||||
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
|
||||
@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||
|
||||
cls.form_data = {
|
||||
'manufacturer': manufacturers[1].pk,
|
||||
'model': 'Device Type X',
|
||||
@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
|
||||
'part_number': '456DEF',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"manufacturer,model,part_number,comments,profile",
|
||||
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,model",
|
||||
f"{module_types[0].id},test model",
|
||||
)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_bulk_update_objects_with_permission(self):
|
||||
self.add_permissions(
|
||||
'dcim.add_consoleporttemplate',
|
||||
'dcim.add_consoleserverporttemplate',
|
||||
'dcim.add_powerporttemplate',
|
||||
'dcim.add_poweroutlettemplate',
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
# run base test
|
||||
super().test_bulk_update_objects_with_permission()
|
||||
|
||||
@tag('regression')
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_bulk_import_objects_with_permission(self):
|
||||
self.add_permissions(
|
||||
'dcim.add_consoleporttemplate',
|
||||
'dcim.add_consoleserverporttemplate',
|
||||
'dcim.add_powerporttemplate',
|
||||
'dcim.add_poweroutlettemplate',
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
# run base test
|
||||
super().test_bulk_import_objects_with_permission()
|
||||
|
||||
# TODO: remove extra regression asserts once parent test supports testing all import fields
|
||||
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
|
||||
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||
|
||||
assert fan_module_type.profile == fan_module_type_profile
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_bulk_import_objects_with_constrained_permission(self):
|
||||
self.add_permissions(
|
||||
'dcim.add_consoleporttemplate',
|
||||
'dcim.add_consoleserverporttemplate',
|
||||
'dcim.add_powerporttemplate',
|
||||
'dcim.add_poweroutlettemplate',
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
super().test_bulk_import_objects_with_constrained_permission()
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_moduletype_consoleports(self):
|
||||
moduletype = ModuleType.objects.first()
|
||||
@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,color",
|
||||
"Device Role 4,device-role-4,ff0000",
|
||||
"Device Role 5,device-role-5,00ff00",
|
||||
"Device Role 6,device-role-6,0000ff",
|
||||
"Device Role 6,device-role-6,ff0000",
|
||||
"Device Role 7,device-role-7,00ff00",
|
||||
"Device Role 8,device-role-8,0000ff",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
@ -1,13 +1,14 @@
|
||||
import functools
|
||||
import operator
|
||||
import re
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'Condition',
|
||||
'ConditionSet',
|
||||
'InvalidCondition',
|
||||
)
|
||||
|
||||
|
||||
AND = 'and'
|
||||
OR = 'or'
|
||||
|
||||
@ -19,6 +20,10 @@ def is_ruleset(data):
|
||||
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
|
||||
|
||||
|
||||
class InvalidCondition(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Condition:
|
||||
"""
|
||||
An individual conditional rule that evaluates a single attribute and its value.
|
||||
@ -61,6 +66,7 @@ class Condition:
|
||||
|
||||
self.attr = attr
|
||||
self.value = value
|
||||
self.op = op
|
||||
self.eval_func = getattr(self, f'eval_{op}')
|
||||
self.negate = negate
|
||||
|
||||
@ -70,16 +76,17 @@ class Condition:
|
||||
"""
|
||||
def _get(obj, key):
|
||||
if isinstance(obj, list):
|
||||
return [dict.get(i, key) for i in obj]
|
||||
|
||||
return dict.get(obj, key)
|
||||
return [operator.getitem(item or {}, key) for item in obj]
|
||||
return operator.getitem(obj or {}, key)
|
||||
|
||||
try:
|
||||
value = functools.reduce(_get, self.attr.split('.'), data)
|
||||
except TypeError:
|
||||
# Invalid key path
|
||||
value = None
|
||||
except KeyError:
|
||||
raise InvalidCondition(f"Invalid key path: {self.attr}")
|
||||
try:
|
||||
result = self.eval_func(value)
|
||||
except TypeError as e:
|
||||
raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
|
||||
|
||||
if self.negate:
|
||||
return not result
|
||||
|
@ -192,5 +192,5 @@ def flush_events(events):
|
||||
try:
|
||||
func = import_string(name)
|
||||
func(events)
|
||||
except Exception as e:
|
||||
except ImportError as e:
|
||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||
|
@ -18,9 +18,22 @@ class Empty(Lookup):
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||
|
||||
|
||||
class NetHost(Lookup):
|
||||
"""
|
||||
Similar to ipam.lookups.NetHost, but casts the field to INET.
|
||||
"""
|
||||
lookup_name = 'net_host'
|
||||
|
||||
def as_sql(self, qn, connection):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
|
||||
|
||||
|
||||
class NetContainsOrEquals(Lookup):
|
||||
"""
|
||||
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
|
||||
Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
|
||||
"""
|
||||
lookup_name = 'net_contains_or_equals'
|
||||
|
||||
@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
|
||||
|
||||
|
||||
CharField.register_lookup(Empty)
|
||||
CachedValueField.register_lookup(NetHost)
|
||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||
|
@ -13,7 +13,7 @@ from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.conditions import ConditionSet
|
||||
from extras.conditions import ConditionSet, InvalidCondition
|
||||
from extras.constants import *
|
||||
from extras.utils import image_upload
|
||||
from extras.models.mixins import RenderTemplateMixin
|
||||
@ -142,7 +142,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
|
||||
if not self.conditions:
|
||||
return True
|
||||
|
||||
return ConditionSet(self.conditions).eval(data)
|
||||
logger = logging.getLogger('netbox.event_rules')
|
||||
|
||||
try:
|
||||
result = ConditionSet(self.conditions).eval(data)
|
||||
logger.debug(f'{self.name}: Evaluated as {result}')
|
||||
return result
|
||||
except InvalidCondition as e:
|
||||
logger.error(f"{self.name}: Evaluation failed. {e}")
|
||||
return False
|
||||
|
||||
|
||||
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
|
@ -4,7 +4,7 @@ from django.test import TestCase
|
||||
from core.events import *
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.conditions import Condition, ConditionSet
|
||||
from extras.conditions import Condition, ConditionSet, InvalidCondition
|
||||
from extras.events import serialize_for_event
|
||||
from extras.forms import EventRuleForm
|
||||
from extras.models import EventRule, Webhook
|
||||
@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook
|
||||
|
||||
class ConditionTestCase(TestCase):
|
||||
|
||||
def test_dotted_path_access(self):
|
||||
c = Condition('a.b.c', 1, 'eq')
|
||||
self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
|
||||
self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
|
||||
self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
|
||||
|
||||
def test_undefined_attr(self):
|
||||
c = Condition('x', 1, 'eq')
|
||||
self.assertFalse(c.eval({}))
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({})
|
||||
|
||||
#
|
||||
# Validation tests
|
||||
@ -37,10 +32,13 @@ class ConditionTestCase(TestCase):
|
||||
# dict type is unsupported
|
||||
Condition('x', 1, dict())
|
||||
|
||||
def test_invalid_op_type(self):
|
||||
def test_invalid_op_types(self):
|
||||
with self.assertRaises(ValueError):
|
||||
# 'gt' supports only numeric values
|
||||
Condition('x', 'foo', 'gt')
|
||||
with self.assertRaises(ValueError):
|
||||
# 'in' supports only iterable values
|
||||
Condition('x', 123, 'in')
|
||||
|
||||
#
|
||||
# Nested attrs tests
|
||||
@ -50,7 +48,10 @@ class ConditionTestCase(TestCase):
|
||||
c = Condition('x.y.z', 1)
|
||||
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
|
||||
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
|
||||
self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': {'y': None}})
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': {'y': {'a': 1}}})
|
||||
|
||||
#
|
||||
# Operator tests
|
||||
@ -74,23 +75,31 @@ class ConditionTestCase(TestCase):
|
||||
c = Condition('x', 1, 'gt')
|
||||
self.assertTrue(c.eval({'x': 2}))
|
||||
self.assertFalse(c.eval({'x': 1}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_gte(self):
|
||||
c = Condition('x', 1, 'gte')
|
||||
self.assertTrue(c.eval({'x': 2}))
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
self.assertFalse(c.eval({'x': 0}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_lt(self):
|
||||
c = Condition('x', 2, 'lt')
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
self.assertFalse(c.eval({'x': 2}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_lte(self):
|
||||
c = Condition('x', 2, 'lte')
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
self.assertTrue(c.eval({'x': 2}))
|
||||
self.assertFalse(c.eval({'x': 3}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_in(self):
|
||||
c = Condition('x', [1, 2, 3], 'in')
|
||||
@ -106,6 +115,8 @@ class ConditionTestCase(TestCase):
|
||||
c = Condition('x', 1, 'contains')
|
||||
self.assertTrue(c.eval({'x': [1, 2, 3]}))
|
||||
self.assertFalse(c.eval({'x': [2, 3, 4]}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 123}) # Invalid type
|
||||
|
||||
def test_contains_negated(self):
|
||||
c = Condition('x', 1, 'contains', negate=True)
|
||||
|
@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
return self.prefix.version
|
||||
return None
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
if self.prefix and self.prefix.version == 6:
|
||||
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||
|
||||
def get_child_prefixes(self):
|
||||
"""
|
||||
Return all Prefixes within this Aggregate
|
||||
@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
def mask_length(self):
|
||||
return self.prefix.prefixlen if self.prefix else None
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
if self.prefix and self.prefix.version == 6:
|
||||
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||
|
||||
@property
|
||||
def depth(self):
|
||||
return self._depth
|
||||
@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
if self.address and self.address.version == 6:
|
||||
return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
|
||||
|
||||
def get_duplicates(self):
|
||||
return IPAddress.objects.filter(
|
||||
vrf=self.vrf,
|
||||
|
@ -8,6 +8,7 @@ from django_pglocks import advisory_lock
|
||||
from rq.timeouts import JobTimeoutException
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.exceptions import JobFailed
|
||||
from core.models import Job, ObjectType
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from netbox.registry import registry
|
||||
@ -73,15 +74,21 @@ class JobRunner(ABC):
|
||||
This method is called by the Job Scheduler to handle the execution of all job commands. It will maintain the
|
||||
job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.jobs')
|
||||
|
||||
try:
|
||||
job.start()
|
||||
cls(job).run(*args, **kwargs)
|
||||
job.terminate()
|
||||
|
||||
except JobFailed:
|
||||
logger.warning(f"Job {job} failed")
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
|
||||
except Exception as e:
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||
if type(e) is JobTimeoutException:
|
||||
logging.error(e)
|
||||
logger.error(e)
|
||||
|
||||
# If the executed job is a periodic job, schedule its next execution at the specified interval.
|
||||
finally:
|
||||
|
@ -1,6 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence, Optional
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
|
||||
__all__ = (
|
||||
'get_model_item',
|
||||
@ -22,20 +24,46 @@ class MenuItemButton:
|
||||
link: str
|
||||
title: str
|
||||
icon_class: str
|
||||
_url: Optional[str] = None
|
||||
permissions: Optional[Sequence[str]] = ()
|
||||
color: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.link:
|
||||
self._url = reverse_lazy(self.link)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuItem:
|
||||
|
||||
link: str
|
||||
link_text: str
|
||||
_url: Optional[str] = None
|
||||
permissions: Optional[Sequence[str]] = ()
|
||||
auth_required: Optional[bool] = False
|
||||
staff_only: Optional[bool] = False
|
||||
buttons: Optional[Sequence[MenuItemButton]] = ()
|
||||
|
||||
def __post_init__(self):
|
||||
if self.link:
|
||||
self._url = reverse_lazy(self.link)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuGroup:
|
||||
|
@ -1,3 +1,4 @@
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@ -32,17 +33,23 @@ class PluginMenuItem:
|
||||
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
||||
specifying additional link buttons that appear to the right of the item in the van menu.
|
||||
|
||||
Links are specified as Django reverse URL strings.
|
||||
Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
|
||||
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
|
||||
Buttons are each specified as a list of PluginMenuButton instances.
|
||||
"""
|
||||
permissions = []
|
||||
buttons = []
|
||||
_url = None
|
||||
|
||||
def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None):
|
||||
def __init__(
|
||||
self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
|
||||
):
|
||||
self.link = link
|
||||
self.link_text = link_text
|
||||
self.auth_required = auth_required
|
||||
self.staff_only = staff_only
|
||||
if link:
|
||||
self._url = reverse_lazy(link)
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
@ -52,6 +59,14 @@ class PluginMenuItem:
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
|
||||
class PluginMenuButton:
|
||||
"""
|
||||
@ -60,11 +75,14 @@ class PluginMenuButton:
|
||||
"""
|
||||
color = ButtonColorChoices.DEFAULT
|
||||
permissions = []
|
||||
_url = None
|
||||
|
||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||
self.link = link
|
||||
self.title = title
|
||||
self.icon_class = icon_class
|
||||
if link:
|
||||
self._url = reverse_lazy(link)
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
@ -73,3 +91,11 @@ class PluginMenuButton:
|
||||
if color not in ButtonColorChoices.values():
|
||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||
self.color = color
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
@ -115,10 +115,12 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||
# "Starts/ends with" matches are valid only on string values
|
||||
query_filter &= Q(type=FieldTypes.STRING)
|
||||
elif lookup == LookupTypes.PARTIAL:
|
||||
elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT):
|
||||
try:
|
||||
# If the value looks like an IP address, add an extra match for CIDR values
|
||||
# If the value looks like an IP address, add extra filters for CIDR/INET values
|
||||
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address)
|
||||
if lookup == LookupTypes.PARTIAL:
|
||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
|
@ -7,11 +7,15 @@ from django_rq import get_queue
|
||||
from ..jobs import *
|
||||
from core.models import DataSource, Job
|
||||
from core.choices import JobStatusChoices
|
||||
from core.exceptions import JobFailed
|
||||
from utilities.testing import disable_warnings
|
||||
|
||||
|
||||
class TestJobRunner(JobRunner):
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
pass
|
||||
if kwargs.get('make_fail', False):
|
||||
raise JobFailed()
|
||||
|
||||
|
||||
class JobRunnerTestCase(TestCase):
|
||||
@ -49,6 +53,12 @@ class JobRunnerTest(JobRunnerTestCase):
|
||||
|
||||
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
|
||||
|
||||
def test_handle_failed(self):
|
||||
with disable_warnings('netbox.jobs'):
|
||||
job = TestJobRunner.enqueue(immediate=True, make_fail=True)
|
||||
|
||||
self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
|
||||
|
||||
def test_handle_errored(self):
|
||||
class ErroredJobRunner(TestJobRunner):
|
||||
EXP = Exception('Test error')
|
||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -23,13 +23,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"@tabler/core": "1.3.2",
|
||||
"@tabler/core": "1.4.0",
|
||||
"bootstrap": "5.3.7",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.2.1",
|
||||
"htmx.org": "2.0.5",
|
||||
"query-string": "9.2.1",
|
||||
"gridstack": "12.2.2",
|
||||
"htmx.org": "2.0.6",
|
||||
"query-string": "9.2.2",
|
||||
"sass": "1.89.2",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
@ -39,15 +39,15 @@
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/node": "^22.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "<9.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "<5.5"
|
||||
},
|
||||
|
4
netbox/project-static/styles/custom/racks.scss
Normal file
4
netbox/project-static/styles/custom/racks.scss
Normal file
@ -0,0 +1,4 @@
|
||||
.rack-loading-container {
|
||||
min-height: 200px;
|
||||
margin-left: 30px;
|
||||
}
|
@ -27,3 +27,4 @@
|
||||
@import 'custom/markdown';
|
||||
@import 'custom/misc';
|
||||
@import 'custom/notifications';
|
||||
@import 'custom/racks';
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,3 +1,3 @@
|
||||
version: "4.3.3"
|
||||
version: "4.3.4"
|
||||
edition: "Community"
|
||||
published: "2025-06-26"
|
||||
published: "2025-07-15"
|
||||
|
@ -1,6 +1,17 @@
|
||||
{% load i18n %}
|
||||
<div style="margin-left: -30px">
|
||||
<object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation" aria-label="{% trans "Rack elevation" %}"></object>
|
||||
<div
|
||||
hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
|
||||
hx-trigger="intersect"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="{% trans "Rack elevation" %}"
|
||||
>
|
||||
<div class="d-flex justify-content-center align-items-center rack-loading-container">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{% trans "Loading..." %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-boost="false">
|
||||
|
@ -14,7 +14,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Base Choices</th>
|
||||
|
@ -29,11 +29,7 @@
|
||||
<div class="hr-text">
|
||||
<span>{% trans "Custom Fields" %}</span>
|
||||
</div>
|
||||
{% for name in filter_form.custom_fields %}
|
||||
{% with field=filter_form|get_item:name %}
|
||||
{% render_field field %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
{% render_custom_fields filter_form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
@ -19,6 +19,10 @@ class ContactGroupTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
contact_count = columns.LinkedCountColumn(
|
||||
viewname='tenancy:contact_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
@ -34,7 +38,7 @@ class ContactGroupTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ContactGroup
|
||||
fields = (
|
||||
'pk', 'name', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||
'pk', 'name', 'parent', 'contact_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'contact_count', 'description')
|
||||
|
@ -16,6 +16,10 @@ class TenantGroupTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
tenant_count = columns.LinkedCountColumn(
|
||||
viewname='tenancy:tenant_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
@ -31,7 +35,7 @@ class TenantGroupTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = TenantGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||
'pk', 'id', 'name', 'parent', 'tenant_count', 'description', 'comments', 'slug', 'tags', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'tenant_count', 'description')
|
||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@ -41,11 +41,11 @@
|
||||
</div>
|
||||
{% for item, buttons in items %}
|
||||
<div class="dropdown-item d-flex justify-content-between ps-3 py-0">
|
||||
<a href="{% url item.link %}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
|
||||
<a href="{{ item.url }}" class="d-inline-flex flex-fill py-1">{{ item.link_text }}</a>
|
||||
{% if buttons %}
|
||||
<div class="btn-group ms-1">
|
||||
{% for button in buttons %}
|
||||
<a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
|
||||
<a href="{{ button.url }}" class="btn btn-sm btn-{{ button.color|default:"outline" }} lh-2 px-2" title="{{ button.title }}">
|
||||
<i class="{{ button.icon_class }}"></i>
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
@ -18,6 +18,10 @@ class WirelessLANGroupTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
wirelesslan_count = columns.LinkedCountColumn(
|
||||
viewname='wireless:wirelesslan_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
@ -33,8 +37,8 @@ class WirelessLANGroupTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = WirelessLANGroup
|
||||
fields = (
|
||||
'pk', 'name', 'wirelesslan_count', 'slug', 'description', 'comments', 'tags', 'created', 'last_updated',
|
||||
'actions',
|
||||
'pk', 'name', 'parent', 'slug', 'description', 'comments', 'tags', 'wirelesslan_count', 'created',
|
||||
'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'wirelesslan_count', 'description')
|
||||
|
||||
|
@ -3,7 +3,7 @@
|
||||
|
||||
[project]
|
||||
name = "netbox"
|
||||
version = "4.3.3"
|
||||
version = "4.3.4"
|
||||
requires-python = ">=3.10"
|
||||
authors = [
|
||||
{ name = "NetBox Community" }
|
||||
|
@ -1,9 +1,9 @@
|
||||
Django==5.2.3
|
||||
Django==5.2.4
|
||||
django-cors-headers==4.7.0
|
||||
django-debug-toolbar==5.2.0
|
||||
django-filter==25.1
|
||||
django-htmx==1.23.1
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-htmx==1.23.2
|
||||
django-mptt==0.17.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.4.1
|
||||
@ -11,29 +11,29 @@ django-redis==6.0.0
|
||||
django-rich==2.0.0
|
||||
django-rq==3.0.1
|
||||
django-storages==1.14.6
|
||||
django-taggit==6.1.0
|
||||
django-tables2==2.7.5
|
||||
django-taggit==6.1.0
|
||||
django-timezone-field==7.1
|
||||
djangorestframework==3.16.0
|
||||
drf-spectacular==0.28.0
|
||||
drf-spectacular-sidecar==2025.6.1
|
||||
drf-spectacular-sidecar==2025.7.1
|
||||
feedparser==6.0.11
|
||||
gunicorn==23.0.0
|
||||
Jinja2==3.1.6
|
||||
jsonschema==4.24.0
|
||||
Markdown==3.8.2
|
||||
mkdocs-material==9.6.14
|
||||
mkdocs-material==9.6.15
|
||||
mkdocstrings[python]==0.29.1
|
||||
netaddr==1.3.0
|
||||
nh3==0.2.21
|
||||
Pillow==11.2.1
|
||||
nh3==0.2.22
|
||||
Pillow==11.3.0
|
||||
psycopg[c,pool]==3.2.9
|
||||
PyYAML==6.0.2
|
||||
requests==2.32.4
|
||||
rq==2.4.0
|
||||
social-auth-app-django==5.4.3
|
||||
social-auth-core==4.6.1
|
||||
strawberry-graphql==0.275.4
|
||||
social-auth-app-django==5.5.1
|
||||
social-auth-core==4.7.0
|
||||
strawberry-graphql==0.276.0
|
||||
strawberry-graphql-django==0.60.0
|
||||
svgwrite==1.4.3
|
||||
tablib==3.8.0
|
||||
|
Loading…
Reference in New Issue
Block a user