mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-14 09:51:22 -06:00
Merge branch 'develop' into feature
This commit is contained in:
commit
25e67eb555
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
15
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -10,16 +10,25 @@ body:
|
|||||||
installation. If you're having trouble with installation or just looking for
|
installation. If you're having trouble with installation or just looking for
|
||||||
assistance with using NetBox, please visit our
|
assistance with using NetBox, please visit our
|
||||||
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
|
[discussion forum](https://github.com/netbox-community/netbox/discussions) instead.
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Deployment Type
|
||||||
|
description: How are you running NetBox?
|
||||||
|
options:
|
||||||
|
- Self-hosted
|
||||||
|
- NetBox Cloud
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: input
|
- type: input
|
||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.6
|
placeholder: v3.6.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
attributes:
|
attributes:
|
||||||
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.8"
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v3.6.6
|
placeholder: v3.6.7
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd
|
|||||||
|
|
||||||
## :bug: Reporting Bugs
|
## :bug: Reporting Bugs
|
||||||
|
|
||||||
|
:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal.
|
||||||
|
|
||||||
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
|
* First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed.
|
||||||
|
|
||||||
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
|
* Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated.
|
||||||
|
@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates
|
|||||||
|
|
||||||
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
|
(Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.)
|
||||||
|
|
||||||
|
|
||||||
!!! info
|
!!! info
|
||||||
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
|
Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends.
|
||||||
|
|
||||||
@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files:
|
|||||||
* Export templates
|
* Export templates
|
||||||
|
|
||||||
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
|
Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data.
|
||||||
|
|
||||||
|
!!! note "Permissions"
|
||||||
|
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source.
|
||||||
|
@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
|
Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md).
|
||||||
|
|
||||||
|
!!! note "Permissions"
|
||||||
|
A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group.
|
||||||
|
|
||||||
The following features support the use of synchronized data:
|
The following features support the use of synchronized data:
|
||||||
|
|
||||||
* [Configuration templates](../features/configuration-rendering.md)
|
* [Configuration templates](../features/configuration-rendering.md)
|
||||||
|
@ -1,6 +1,33 @@
|
|||||||
# NetBox v3.6
|
# NetBox v3.6
|
||||||
|
|
||||||
## v3.6.7 (FUTURE)
|
## v3.6.8 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v3.6.7 (2023-12-15)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget
|
||||||
|
* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column
|
||||||
|
* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag
|
||||||
|
* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices
|
||||||
|
* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects
|
||||||
|
* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6
|
||||||
|
* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI
|
||||||
|
* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints
|
||||||
|
* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero
|
||||||
|
* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device
|
||||||
|
* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites
|
||||||
|
* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations
|
||||||
|
* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode
|
||||||
|
* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts
|
||||||
|
* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group
|
||||||
|
* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -119,6 +119,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id')
|
||||||
type_id = DynamicModelMultipleChoiceField(
|
type_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=CircuitType.objects.all(),
|
queryset=CircuitType.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -165,6 +165,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
|||||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'region_id', 'group_id')
|
||||||
status = forms.MultipleChoiceField(
|
status = forms.MultipleChoiceField(
|
||||||
label=_('Status'),
|
label=_('Status'),
|
||||||
choices=SiteStatusChoices,
|
choices=SiteStatusChoices,
|
||||||
@ -248,6 +249,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
|||||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||||
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
|
(_('Weight'), ('weight', 'max_weight', 'weight_unit')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -420,6 +422,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm):
|
|||||||
)),
|
)),
|
||||||
(_('Weight'), ('weight', 'weight_unit')),
|
(_('Weight'), ('weight', 'weight_unit')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -544,6 +547,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
|
|||||||
)),
|
)),
|
||||||
(_('Weight'), ('weight', 'weight_unit')),
|
(_('Weight'), ('weight', 'weight_unit')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -620,6 +624,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm):
|
|||||||
|
|
||||||
class PlatformFilterForm(NetBoxModelFilterSetForm):
|
class PlatformFilterForm(NetBoxModelFilterSetForm):
|
||||||
model = Platform
|
model = Platform
|
||||||
|
selector_fields = ('filter_id', 'q', 'manufacturer_id')
|
||||||
manufacturer_id = DynamicModelMultipleChoiceField(
|
manufacturer_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Manufacturer.objects.all(),
|
queryset=Manufacturer.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -654,6 +659,7 @@ class DeviceFilterForm(
|
|||||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -997,6 +1003,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
|
|||||||
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
(_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')),
|
||||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'site_id', 'location_id')
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -1228,6 +1235,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
|||||||
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
(_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')),
|
||||||
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
(_('Connection'), ('cabled', 'connected', 'occupied')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'device_id')
|
||||||
vdc_id = DynamicModelMultipleChoiceField(
|
vdc_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=VirtualDeviceContext.objects.all(),
|
queryset=VirtualDeviceContext.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
# Generated by Django 4.1.9 on 2023-05-31 15:47
|
# Generated by Django 4.1.9 on 2023-05-31 15:47
|
||||||
|
|
||||||
|
import django.core.validators
|
||||||
from django.db import migrations, models
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
@ -12,6 +13,6 @@ class Migration(migrations.Migration):
|
|||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='rack',
|
model_name='rack',
|
||||||
name='starting_unit',
|
name='starting_unit',
|
||||||
field=models.PositiveSmallIntegerField(default=1),
|
field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from extras.models import ConfigContextModel
|
from extras.models import ConfigContextModel, CustomField
|
||||||
from extras.querysets import ConfigContextModelQuerySet
|
from extras.querysets import ConfigContextModelQuerySet
|
||||||
from netbox.config import ConfigItem
|
from netbox.config import ConfigItem
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
@ -994,11 +994,17 @@ class Device(
|
|||||||
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
bulk_create: If True, bulk_create() will be called to create all components in a single query
|
||||||
(default). Otherwise, save() will be called on each instance individually.
|
(default). Otherwise, save() will be called on each instance individually.
|
||||||
"""
|
"""
|
||||||
if bulk_create:
|
|
||||||
components = [obj.instantiate(device=self) for obj in queryset]
|
components = [obj.instantiate(device=self) for obj in queryset]
|
||||||
if not components:
|
if not components:
|
||||||
return
|
return
|
||||||
model = components[0]._meta.model
|
|
||||||
|
# Set default values for any applicable custom fields
|
||||||
|
model = queryset.model.component_model
|
||||||
|
if cf_defaults := CustomField.objects.get_defaults_for_model(model):
|
||||||
|
for component in components:
|
||||||
|
component.custom_field_data = cf_defaults
|
||||||
|
|
||||||
|
if bulk_create:
|
||||||
model.objects.bulk_create(components)
|
model.objects.bulk_create(components)
|
||||||
# Manually send the post_save signal for each of the newly created components
|
# Manually send the post_save signal for each of the newly created components
|
||||||
for component in components:
|
for component in components:
|
||||||
@ -1011,8 +1017,7 @@ class Device(
|
|||||||
update_fields=None
|
update_fields=None
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
for obj in queryset:
|
for component in components:
|
||||||
component = obj.instantiate(device=self)
|
|
||||||
component.save()
|
component.save()
|
||||||
|
|
||||||
def save(self, *args, **kwargs):
|
def save(self, *args, **kwargs):
|
||||||
|
@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
|
|||||||
# Rack must belong to same Site as PowerPanel
|
# Rack must belong to same Site as PowerPanel
|
||||||
if self.rack and self.rack.site != self.power_panel.site:
|
if self.rack and self.rack.site != self.power_panel.site:
|
||||||
raise ValidationError(_(
|
raise ValidationError(_(
|
||||||
"Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites"
|
"Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites."
|
||||||
).format(
|
).format(
|
||||||
rack=self.rack,
|
rack=self.rack,
|
||||||
rack_site=self.rack.site,
|
rack_site=self.rack.site,
|
||||||
|
@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
|||||||
starting_unit = models.PositiveSmallIntegerField(
|
starting_unit = models.PositiveSmallIntegerField(
|
||||||
default=RACK_STARTING_UNIT_DEFAULT,
|
default=RACK_STARTING_UNIT_DEFAULT,
|
||||||
verbose_name=_('starting unit'),
|
verbose_name=_('starting unit'),
|
||||||
|
validators=[MinValueValidator(1),],
|
||||||
help_text=_('Starting unit for rack')
|
help_text=_('Starting unit for rack')
|
||||||
)
|
)
|
||||||
desc_units = models.BooleanField(
|
desc_units = models.BooleanField(
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.models import *
|
from dcim.models import *
|
||||||
|
from extras.models import CustomField
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.utils import drange
|
from utilities.utils import drange
|
||||||
|
|
||||||
@ -289,6 +291,23 @@ class DeviceTestCase(TestCase):
|
|||||||
)
|
)
|
||||||
DeviceRole.objects.bulk_create(roles)
|
DeviceRole.objects.bulk_create(roles)
|
||||||
|
|
||||||
|
# Create a CustomField with a default value & assign it to all component models
|
||||||
|
cf1 = CustomField.objects.create(name='cf1', default='foo')
|
||||||
|
cf1.content_types.set(
|
||||||
|
ContentType.objects.filter(app_label='dcim', model__in=[
|
||||||
|
'consoleport',
|
||||||
|
'consoleserverport',
|
||||||
|
'powerport',
|
||||||
|
'poweroutlet',
|
||||||
|
'interface',
|
||||||
|
'rearport',
|
||||||
|
'frontport',
|
||||||
|
'modulebay',
|
||||||
|
'devicebay',
|
||||||
|
'inventoryitem',
|
||||||
|
])
|
||||||
|
)
|
||||||
|
|
||||||
# Create DeviceType components
|
# Create DeviceType components
|
||||||
ConsolePortTemplate(
|
ConsolePortTemplate(
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
@ -300,18 +319,18 @@ class DeviceTestCase(TestCase):
|
|||||||
name='Console Server Port 1'
|
name='Console Server Port 1'
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
ppt = PowerPortTemplate(
|
powerport = PowerPortTemplate(
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
name='Power Port 1',
|
name='Power Port 1',
|
||||||
maximum_draw=1000,
|
maximum_draw=1000,
|
||||||
allocated_draw=500
|
allocated_draw=500
|
||||||
)
|
)
|
||||||
ppt.save()
|
powerport.save()
|
||||||
|
|
||||||
PowerOutletTemplate(
|
PowerOutletTemplate(
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
name='Power Outlet 1',
|
name='Power Outlet 1',
|
||||||
power_port=ppt,
|
power_port=powerport,
|
||||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
@ -322,19 +341,19 @@ class DeviceTestCase(TestCase):
|
|||||||
mgmt_only=True
|
mgmt_only=True
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
rpt = RearPortTemplate(
|
rearport = RearPortTemplate(
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
name='Rear Port 1',
|
name='Rear Port 1',
|
||||||
type=PortTypeChoices.TYPE_8P8C,
|
type=PortTypeChoices.TYPE_8P8C,
|
||||||
positions=8
|
positions=8
|
||||||
)
|
)
|
||||||
rpt.save()
|
rearport.save()
|
||||||
|
|
||||||
FrontPortTemplate(
|
FrontPortTemplate(
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
name='Front Port 1',
|
name='Front Port 1',
|
||||||
type=PortTypeChoices.TYPE_8P8C,
|
type=PortTypeChoices.TYPE_8P8C,
|
||||||
rear_port=rpt,
|
rear_port=rearport,
|
||||||
rear_port_position=2
|
rear_port_position=2
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
@ -348,73 +367,93 @@ class DeviceTestCase(TestCase):
|
|||||||
name='Device Bay 1'
|
name='Device Bay 1'
|
||||||
).save()
|
).save()
|
||||||
|
|
||||||
|
InventoryItemTemplate(
|
||||||
|
device_type=device_type,
|
||||||
|
name='Inventory Item 1'
|
||||||
|
).save()
|
||||||
|
|
||||||
def test_device_creation(self):
|
def test_device_creation(self):
|
||||||
"""
|
"""
|
||||||
Ensure that all Device components are copied automatically from the DeviceType.
|
Ensure that all Device components are copied automatically from the DeviceType.
|
||||||
"""
|
"""
|
||||||
d = Device(
|
device = Device(
|
||||||
site=Site.objects.first(),
|
site=Site.objects.first(),
|
||||||
device_type=DeviceType.objects.first(),
|
device_type=DeviceType.objects.first(),
|
||||||
role=DeviceRole.objects.first(),
|
role=DeviceRole.objects.first(),
|
||||||
name='Test Device 1'
|
name='Test Device 1'
|
||||||
)
|
)
|
||||||
d.save()
|
device.save()
|
||||||
|
|
||||||
ConsolePort.objects.get(
|
consoleport = ConsolePort.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Console Port 1'
|
name='Console Port 1'
|
||||||
)
|
)
|
||||||
|
self.assertEqual(consoleport.cf['cf1'], 'foo')
|
||||||
|
|
||||||
ConsoleServerPort.objects.get(
|
consoleserverport = ConsoleServerPort.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Console Server Port 1'
|
name='Console Server Port 1'
|
||||||
)
|
)
|
||||||
|
self.assertEqual(consoleserverport.cf['cf1'], 'foo')
|
||||||
|
|
||||||
pp = PowerPort.objects.get(
|
powerport = PowerPort.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Power Port 1',
|
name='Power Port 1',
|
||||||
maximum_draw=1000,
|
maximum_draw=1000,
|
||||||
allocated_draw=500
|
allocated_draw=500
|
||||||
)
|
)
|
||||||
|
self.assertEqual(powerport.cf['cf1'], 'foo')
|
||||||
|
|
||||||
PowerOutlet.objects.get(
|
poweroutlet = PowerOutlet.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Power Outlet 1',
|
name='Power Outlet 1',
|
||||||
power_port=pp,
|
power_port=powerport,
|
||||||
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A
|
||||||
)
|
)
|
||||||
|
self.assertEqual(poweroutlet.cf['cf1'], 'foo')
|
||||||
|
|
||||||
Interface.objects.get(
|
interface = Interface.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Interface 1',
|
name='Interface 1',
|
||||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||||
mgmt_only=True
|
mgmt_only=True
|
||||||
)
|
)
|
||||||
|
self.assertEqual(interface.cf['cf1'], 'foo')
|
||||||
|
|
||||||
rp = RearPort.objects.get(
|
rearport = RearPort.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Rear Port 1',
|
name='Rear Port 1',
|
||||||
type=PortTypeChoices.TYPE_8P8C,
|
type=PortTypeChoices.TYPE_8P8C,
|
||||||
positions=8
|
positions=8
|
||||||
)
|
)
|
||||||
|
self.assertEqual(rearport.cf['cf1'], 'foo')
|
||||||
|
|
||||||
FrontPort.objects.get(
|
frontport = FrontPort.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Front Port 1',
|
name='Front Port 1',
|
||||||
type=PortTypeChoices.TYPE_8P8C,
|
type=PortTypeChoices.TYPE_8P8C,
|
||||||
rear_port=rp,
|
rear_port=rearport,
|
||||||
rear_port_position=2
|
rear_port_position=2
|
||||||
)
|
)
|
||||||
|
self.assertEqual(frontport.cf['cf1'], 'foo')
|
||||||
|
|
||||||
ModuleBay.objects.get(
|
modulebay = ModuleBay.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Module Bay 1'
|
name='Module Bay 1'
|
||||||
)
|
)
|
||||||
|
self.assertEqual(modulebay.cf['cf1'], 'foo')
|
||||||
|
|
||||||
DeviceBay.objects.get(
|
devicebay = DeviceBay.objects.get(
|
||||||
device=d,
|
device=device,
|
||||||
name='Device Bay 1'
|
name='Device Bay 1'
|
||||||
)
|
)
|
||||||
|
self.assertEqual(devicebay.cf['cf1'], 'foo')
|
||||||
|
|
||||||
|
inventoryitem = InventoryItem.objects.get(
|
||||||
|
device=device,
|
||||||
|
name='Inventory Item 1'
|
||||||
|
)
|
||||||
|
self.assertEqual(inventoryitem.cf['cf1'], 'foo')
|
||||||
|
|
||||||
def test_multiple_unnamed_devices(self):
|
def test_multiple_unnamed_devices(self):
|
||||||
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.postgres.forms import SimpleArrayField
|
from django.contrib.postgres.forms import SimpleArrayField
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
from django.core.exceptions import ObjectDoesNotExist
|
||||||
@ -82,7 +84,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
|||||||
extra_choices = SimpleArrayField(
|
extra_choices = SimpleArrayField(
|
||||||
base_field=forms.CharField(),
|
base_field=forms.CharField(),
|
||||||
required=False,
|
required=False,
|
||||||
help_text=_('Comma-separated list of field choices')
|
help_text=_(
|
||||||
|
'Quoted string of comma-separated field choices with optional labels separated by colon: '
|
||||||
|
'"choice1:First Choice,choice2:Second Choice"'
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
@ -91,6 +96,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm):
|
|||||||
'name', 'description', 'extra_choices', 'order_alphabetically',
|
'name', 'description', 'extra_choices', 'order_alphabetically',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def clean_extra_choices(self):
|
||||||
|
if isinstance(self.cleaned_data['extra_choices'], list):
|
||||||
|
data = []
|
||||||
|
for line in self.cleaned_data['extra_choices']:
|
||||||
|
try:
|
||||||
|
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||||
|
value = value.replace('\\:', ':')
|
||||||
|
label = label.replace('\\:', ':')
|
||||||
|
except ValueError:
|
||||||
|
value, label = line, line
|
||||||
|
data.append((value, label))
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkImportForm(CSVModelForm):
|
class CustomLinkImportForm(CSVModelForm):
|
||||||
content_types = CSVMultipleContentTypeField(
|
content_types = CSVMultipleContentTypeField(
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import json
|
import json
|
||||||
|
import re
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
@ -88,19 +89,33 @@ class CustomFieldChoiceSetForm(BootstrapMixin, forms.ModelForm):
|
|||||||
required=False,
|
required=False,
|
||||||
help_text=mark_safe(_(
|
help_text=mark_safe(_(
|
||||||
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
'Enter one choice per line. An optional label may be specified for each choice by appending it with a '
|
||||||
'comma. Example:'
|
'colon. Example:'
|
||||||
) + ' <code>choice1,First Choice</code>')
|
) + ' <code>choice1:First Choice</code>')
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CustomFieldChoiceSet
|
model = CustomFieldChoiceSet
|
||||||
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
|
fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically')
|
||||||
|
|
||||||
|
def __init__(self, *args, initial=None, **kwargs):
|
||||||
|
super().__init__(*args, initial=initial, **kwargs)
|
||||||
|
|
||||||
|
# Escape colons in extra_choices
|
||||||
|
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||||
|
choices = []
|
||||||
|
for choice in self.initial['extra_choices']:
|
||||||
|
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
||||||
|
choices.append(choice)
|
||||||
|
|
||||||
|
self.initial['extra_choices'] = choices
|
||||||
|
|
||||||
def clean_extra_choices(self):
|
def clean_extra_choices(self):
|
||||||
data = []
|
data = []
|
||||||
for line in self.cleaned_data['extra_choices'].splitlines():
|
for line in self.cleaned_data['extra_choices'].splitlines():
|
||||||
try:
|
try:
|
||||||
value, label = line.split(',', maxsplit=1)
|
value, label = re.split(r'(?<!\\):', line, maxsplit=1)
|
||||||
|
value = value.replace('\\:', ':')
|
||||||
|
label = label.replace('\\:', ':')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
value, label = line, line
|
value, label = line, line
|
||||||
data.append((value, label))
|
data.append((value, label))
|
||||||
|
@ -55,6 +55,15 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
|
|||||||
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
|
content_type = ContentType.objects.get_for_model(model._meta.concrete_model)
|
||||||
return self.get_queryset().filter(content_types=content_type)
|
return self.get_queryset().filter(content_types=content_type)
|
||||||
|
|
||||||
|
def get_defaults_for_model(self, model):
|
||||||
|
"""
|
||||||
|
Return a dictionary of serialized default values for all CustomFields applicable to the given model.
|
||||||
|
"""
|
||||||
|
custom_fields = self.get_for_model(model).filter(default__isnull=False)
|
||||||
|
return {
|
||||||
|
cf.name: cf.default for cf in custom_fields
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||||
content_types = models.ManyToManyField(
|
content_types = models.ManyToManyField(
|
||||||
|
@ -92,19 +92,24 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
name='Choice Set 3',
|
name='Choice Set 3',
|
||||||
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
|
extra_choices=(('C1', 'Choice 1'), ('C2', 'Choice 2'), ('C3', 'Choice 3'))
|
||||||
),
|
),
|
||||||
|
CustomFieldChoiceSet(
|
||||||
|
name='Choice Set 4',
|
||||||
|
extra_choices=(('D1', 'Choice 1'), ('D2', 'Choice 2'), ('D3', 'Choice 3'))
|
||||||
|
),
|
||||||
)
|
)
|
||||||
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
CustomFieldChoiceSet.objects.bulk_create(choice_sets)
|
||||||
|
|
||||||
cls.form_data = {
|
cls.form_data = {
|
||||||
'name': 'Choice Set X',
|
'name': 'Choice Set X',
|
||||||
'extra_choices': '\n'.join(['X1,Choice 1', 'X2,Choice 2', 'X3,Choice 3'])
|
'extra_choices': '\n'.join(['X1:Choice 1', 'X2:Choice 2', 'X3:Choice 3'])
|
||||||
}
|
}
|
||||||
|
|
||||||
cls.csv_data = (
|
cls.csv_data = (
|
||||||
'name,extra_choices',
|
'name,extra_choices',
|
||||||
'Choice Set 4,"D1,D2,D3"',
|
'Choice Set 5,"D1,D2,D3"',
|
||||||
'Choice Set 5,"E1,E2,E3"',
|
'Choice Set 6,"E1,E2,E3"',
|
||||||
'Choice Set 6,"F1,F2,F3"',
|
'Choice Set 7,"F1,F2,F3"',
|
||||||
|
'Choice Set 8,"F1:L1,F2:L2,F3:L3"',
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.csv_update_data = (
|
cls.csv_update_data = (
|
||||||
@ -112,12 +117,20 @@ class CustomFieldChoiceSetTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
f'{choice_sets[0].pk},"A,B,C"',
|
f'{choice_sets[0].pk},"A,B,C"',
|
||||||
f'{choice_sets[1].pk},"A,B,C"',
|
f'{choice_sets[1].pk},"A,B,C"',
|
||||||
f'{choice_sets[2].pk},"A,B,C"',
|
f'{choice_sets[2].pk},"A,B,C"',
|
||||||
|
f'{choice_sets[3].pk},"A:L1,B:L2,C:L3"',
|
||||||
)
|
)
|
||||||
|
|
||||||
cls.bulk_edit_data = {
|
cls.bulk_edit_data = {
|
||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# This is here as extra_choices field splits on colon, but is returned
|
||||||
|
# from DB as comma separated.
|
||||||
|
def assertInstanceEqual(self, instance, data, exclude=None, api=False):
|
||||||
|
if 'extra_choices' in data:
|
||||||
|
data['extra_choices'] = data['extra_choices'].replace(':', ',')
|
||||||
|
return super().assertInstanceEqual(instance, data, exclude, api)
|
||||||
|
|
||||||
|
|
||||||
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class CustomLinkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = CustomLink
|
model = CustomLink
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
from copy import deepcopy
|
||||||
|
|
||||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
@ -277,7 +279,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Prepare object data for deserialization
|
# Prepare object data for deserialization
|
||||||
requested_objects = self.prep_object_data(requested_objects, available_objects, parent)
|
requested_objects = self.prep_object_data(deepcopy(requested_objects), available_objects, parent)
|
||||||
|
|
||||||
# Initialize the serializer with a list or a single object depending on what was requested
|
# Initialize the serializer with a list or a single object depending on what was requested
|
||||||
serializer_class = get_serializer_for_model(self.queryset.model)
|
serializer_class = get_serializer_for_model(self.queryset.model)
|
||||||
|
@ -296,6 +296,7 @@ class IPAddressFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||||
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
|
(_('Device/VM'), ('device_id', 'virtual_machine_id')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'region_id', 'group_id', 'parent', 'status', 'role')
|
||||||
parent = forms.CharField(
|
parent = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
widget=forms.TextInput(
|
widget=forms.TextInput(
|
||||||
@ -448,6 +449,7 @@ class VLANFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
|
(_('Attributes'), ('group_id', 'status', 'role_id', 'vid', 'l2vpn_id')),
|
||||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'site_id')
|
||||||
region_id = DynamicModelMultipleChoiceField(
|
region_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Region.objects.all(),
|
queryset=Region.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -951,7 +951,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
|
|||||||
|
|
||||||
def prep_table_data(self, request, queryset, parent):
|
def prep_table_data(self, request, queryset, parent):
|
||||||
if not get_table_ordering(request, self.table):
|
if not get_table_ordering(request, self.table):
|
||||||
return add_available_vlans(parent.get_child_vlans(), parent)
|
return add_available_vlans(queryset, parent)
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
@ -56,8 +56,15 @@ class BriefModeMixin:
|
|||||||
def get_queryset(self):
|
def get_queryset(self):
|
||||||
qs = super().get_queryset()
|
qs = super().get_queryset()
|
||||||
|
|
||||||
# If using brief mode, clear all prefetches from the queryset and append only brief_prefetch_fields (if any)
|
|
||||||
if self.brief:
|
if self.brief:
|
||||||
|
serializer_class = self.get_serializer_class()
|
||||||
|
|
||||||
|
# Clear any annotations for fields not present on the nested serializer
|
||||||
|
for annotation in list(qs.query.annotations.keys()):
|
||||||
|
if annotation not in serializer_class().fields:
|
||||||
|
qs.query.annotations.pop(annotation)
|
||||||
|
|
||||||
|
# Clear any prefetches from the queryset and append only brief_prefetch_fields (if any)
|
||||||
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
return qs.prefetch_related(None).prefetch_related(*self.brief_prefetch_fields)
|
||||||
|
|
||||||
return qs
|
return qs
|
||||||
|
@ -144,12 +144,16 @@ class NetBoxModelFilterSetForm(BootstrapMixin, CustomFieldsMixin, SavedFiltersMi
|
|||||||
model: The model class associated with the form
|
model: The model class associated with the form
|
||||||
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
||||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||||
|
selector_fields: An iterable of names of fields to display by default when rendering the form as
|
||||||
|
a selector widget
|
||||||
"""
|
"""
|
||||||
q = forms.CharField(
|
q = forms.CharField(
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Search')
|
label=_('Search')
|
||||||
)
|
)
|
||||||
|
|
||||||
|
selector_fields = ('filter_id', 'q')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
super().__init__(*args, **kwargs)
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
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.
@ -264,6 +264,11 @@ export class APISelect {
|
|||||||
switch (this.trigger) {
|
switch (this.trigger) {
|
||||||
case 'collapse':
|
case 'collapse':
|
||||||
if (collapse !== null) {
|
if (collapse !== null) {
|
||||||
|
// If the element is collapsible but already shown, load the data immediately.
|
||||||
|
if (collapse.classList.contains('show')) {
|
||||||
|
Promise.all([this.loadData()]);
|
||||||
|
}
|
||||||
|
|
||||||
// If this element is part of a collapsible element, only load the data when the
|
// If this element is part of a collapsible element, only load the data when the
|
||||||
// collapsible element is shown.
|
// collapsible element is shown.
|
||||||
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
|
// See: https://getbootstrap.com/docs/5.0/components/collapse/#events
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
{% comment %}
|
{% comment %}
|
||||||
Include a hidden field of the same name to ensure that unchecked checkboxes
|
Include a hidden field of the same name to ensure that unchecked checkboxes
|
||||||
are always included in the submitted form data.
|
are always included in the submitted form data. Omit fields names
|
||||||
|
_selected_action to avoid breaking the admin UI.
|
||||||
{% endcomment %}
|
{% endcomment %}
|
||||||
<input type="hidden" name="{{ widget.name }}" value="">
|
{% if widget.name != '_selected_action' %}<input type="hidden" name="{{ widget.name }}" value="">{% endif %}
|
||||||
{% include "django/forms/widgets/input.html" %}
|
{% include "django/forms/widgets/input.html" %}
|
||||||
|
@ -10,18 +10,18 @@
|
|||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
|
<a href="#" class="list-group-item list-group-item-action px-0 py-1" data-bs-toggle="collapse" data-bs-target="#checkmark{{ forloop.counter }}, #selector{{ forloop.counter }}">
|
||||||
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
<span id="checkmark{{ forloop.counter }}" class="collapse{% if forloop.counter < 3 or field.name in form.selector_fields %} show{% endif %}"><i class="mdi mdi-check-bold"></i></span>
|
||||||
{{ field.label }}
|
{{ field.label }}
|
||||||
</a>
|
</a>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-9">
|
<div class="col-9">
|
||||||
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, keyup from:#id_q delay:500ms">
|
<form hx-get="{% url 'htmx_object_selector' %}?_model={{ model|meta:"label_lower" }}" hx-target="#selector_results" hx-trigger="load, submit, change, keyup from:#id_q delay:500ms">
|
||||||
<input type="hidden" name="_search" value="true" />
|
<input type="hidden" name="_search" value="true" />
|
||||||
<div class="tab-content p-1">
|
<div class="tab-content p-1">
|
||||||
{% for field in form.visible_fields %}
|
{% for field in form.visible_fields %}
|
||||||
<div class="collapse{% if forloop.counter < 3 %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
<div class="collapse{% if field.name in form.selector_fields %} show{% endif %}" id="selector{{ forloop.counter }}">{% render_field field %}</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-end">
|
<div class="text-end">
|
||||||
|
@ -90,6 +90,19 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet):
|
|||||||
queryset=Contact.objects.all(),
|
queryset=Contact.objects.all(),
|
||||||
label=_('Contact (ID)'),
|
label=_('Contact (ID)'),
|
||||||
)
|
)
|
||||||
|
group_id = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=ContactGroup.objects.all(),
|
||||||
|
field_name='contact__group',
|
||||||
|
lookup_expr='in',
|
||||||
|
label=_('Contact group (ID)'),
|
||||||
|
)
|
||||||
|
group = TreeNodeMultipleChoiceFilter(
|
||||||
|
queryset=ContactGroup.objects.all(),
|
||||||
|
field_name='contact__group',
|
||||||
|
lookup_expr='in',
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Contact group (slug)'),
|
||||||
|
)
|
||||||
role_id = django_filters.ModelMultipleChoiceFilter(
|
role_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
queryset=ContactRole.objects.all(),
|
queryset=ContactRole.objects.all(),
|
||||||
label=_('Contact role (ID)'),
|
label=_('Contact role (ID)'),
|
||||||
|
@ -1,5 +1,7 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.models import Manufacturer, Site
|
||||||
from tenancy.filtersets import *
|
from tenancy.filtersets import *
|
||||||
from tenancy.models import *
|
from tenancy.models import *
|
||||||
from utilities.testing import ChangeLoggedFilterSetTests
|
from utilities.testing import ChangeLoggedFilterSetTests
|
||||||
@ -192,3 +194,72 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
params = {'group': [group[0].slug, group[1].slug]}
|
params = {'group': [group[0].slug, group[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
|
class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
|
queryset = ContactAssignment.objects.all()
|
||||||
|
filterset = ContactAssignmentFilterSet
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def setUpTestData(cls):
|
||||||
|
|
||||||
|
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
|
||||||
|
sites = (
|
||||||
|
Site(name='Site 1', slug='site-1'),
|
||||||
|
Site(name='Site 2', slug='site-2'),
|
||||||
|
Site(name='Site 3', slug='site-3'),
|
||||||
|
)
|
||||||
|
Site.objects.bulk_create(sites)
|
||||||
|
|
||||||
|
contact_groups = (
|
||||||
|
ContactGroup(name='Contact Group 1', slug='contact-group-1'),
|
||||||
|
ContactGroup(name='Contact Group 2', slug='contact-group-2'),
|
||||||
|
ContactGroup(name='Contact Group 3', slug='contact-group-3'),
|
||||||
|
)
|
||||||
|
for contactgroup in contact_groups:
|
||||||
|
contactgroup.save()
|
||||||
|
|
||||||
|
contact_roles = (
|
||||||
|
ContactRole(name='Contact Role 1', slug='contact-role-1'),
|
||||||
|
ContactRole(name='Contact Role 2', slug='contact-role-2'),
|
||||||
|
ContactRole(name='Contact Role 3', slug='contact-role-3'),
|
||||||
|
)
|
||||||
|
ContactRole.objects.bulk_create(contact_roles)
|
||||||
|
|
||||||
|
contacts = (
|
||||||
|
Contact(name='Contact 1', group=contact_groups[0]),
|
||||||
|
Contact(name='Contact 2', group=contact_groups[1]),
|
||||||
|
Contact(name='Contact 3', group=contact_groups[2]),
|
||||||
|
)
|
||||||
|
Contact.objects.bulk_create(contacts)
|
||||||
|
|
||||||
|
assignments = (
|
||||||
|
ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]),
|
||||||
|
ContactAssignment(object=sites[1], contact=contacts[1], role=contact_roles[1]),
|
||||||
|
ContactAssignment(object=sites[2], contact=contacts[2], role=contact_roles[2]),
|
||||||
|
ContactAssignment(object=manufacturer, contact=contacts[2], role=contact_roles[2]),
|
||||||
|
)
|
||||||
|
ContactAssignment.objects.bulk_create(assignments)
|
||||||
|
|
||||||
|
def test_content_type(self):
|
||||||
|
params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
|
def test_contact(self):
|
||||||
|
contacts = Contact.objects.all()[:2]
|
||||||
|
params = {'contact_id': [contacts[0].pk, contacts[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_group(self):
|
||||||
|
group = ContactGroup.objects.all()[:2]
|
||||||
|
params = {'group_id': [group[0].pk, group[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'group': [group[0].slug, group[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_role(self):
|
||||||
|
role = ContactRole.objects.all()[:2]
|
||||||
|
params = {'role_id': [role[0].pk, role[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
params = {'role': [role[0].slug, role[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
@ -220,6 +220,7 @@ class UserConfig(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
@receiver(post_save, sender=User)
|
@receiver(post_save, sender=User)
|
||||||
|
@receiver(post_save, sender=NetBoxUser)
|
||||||
def create_userconfig(instance, created, raw=False, **kwargs):
|
def create_userconfig(instance, created, raw=False, **kwargs):
|
||||||
"""
|
"""
|
||||||
Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.
|
Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture.
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
from django.db.models import F, Count, OuterRef, Subquery
|
from django.db.models import F, Count, OuterRef, Subquery
|
||||||
from django.db.models.signals import post_delete, post_save
|
from django.db.models.signals import post_delete, post_save, pre_delete
|
||||||
|
|
||||||
from netbox.registry import registry
|
from netbox.registry import registry
|
||||||
from .fields import CounterCacheField
|
from .fields import CounterCacheField
|
||||||
@ -62,6 +62,12 @@ def post_save_receiver(sender, instance, created, **kwargs):
|
|||||||
update_counter(parent_model, new_pk, counter_name, 1)
|
update_counter(parent_model, new_pk, counter_name, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def pre_delete_receiver(sender, instance, origin, **kwargs):
|
||||||
|
model = instance._meta.model
|
||||||
|
if not model.objects.filter(pk=instance.pk).exists():
|
||||||
|
instance._previously_removed = True
|
||||||
|
|
||||||
|
|
||||||
def post_delete_receiver(sender, instance, origin, **kwargs):
|
def post_delete_receiver(sender, instance, origin, **kwargs):
|
||||||
"""
|
"""
|
||||||
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
|
Update counter fields on related objects when a TrackingModelMixin subclass is deleted.
|
||||||
@ -71,9 +77,7 @@ def post_delete_receiver(sender, instance, origin, **kwargs):
|
|||||||
parent_pk = getattr(instance, field_name, None)
|
parent_pk = getattr(instance, field_name, None)
|
||||||
|
|
||||||
# Decrement the parent's counter by one
|
# Decrement the parent's counter by one
|
||||||
if parent_pk is not None:
|
if parent_pk is not None and not hasattr(instance, "_previously_removed"):
|
||||||
# MPTT sends two delete signals for child elements so guard against multiple decrements
|
|
||||||
if not origin or origin == instance:
|
|
||||||
update_counter(parent_model, parent_pk, counter_name, -1)
|
update_counter(parent_model, parent_pk, counter_name, -1)
|
||||||
|
|
||||||
|
|
||||||
@ -106,6 +110,12 @@ def connect_counters(*models):
|
|||||||
weak=False,
|
weak=False,
|
||||||
dispatch_uid=f'{model._meta.label}.{field.name}'
|
dispatch_uid=f'{model._meta.label}.{field.name}'
|
||||||
)
|
)
|
||||||
|
pre_delete.connect(
|
||||||
|
pre_delete_receiver,
|
||||||
|
sender=to_model,
|
||||||
|
weak=False,
|
||||||
|
dispatch_uid=f'{model._meta.label}.{field.name}'
|
||||||
|
)
|
||||||
post_delete.connect(
|
post_delete.connect(
|
||||||
post_delete_receiver,
|
post_delete_receiver,
|
||||||
sender=to_model,
|
sender=to_model,
|
||||||
|
@ -65,5 +65,5 @@ class ChoicesWidget(forms.Textarea):
|
|||||||
if not value:
|
if not value:
|
||||||
return None
|
return None
|
||||||
if type(value) is list:
|
if type(value) is list:
|
||||||
return '\n'.join([f'{k},{v}' for k, v in value])
|
return '\n'.join([f'{k}:{v}' for k, v in value])
|
||||||
return value
|
return value
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from netaddr import IPAddress
|
from netaddr import AddrFormatError, IPAddress
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'get_client_ip',
|
'get_client_ip',
|
||||||
@ -17,11 +18,18 @@ def get_client_ip(request, additional_headers=()):
|
|||||||
)
|
)
|
||||||
for header in HTTP_HEADERS:
|
for header in HTTP_HEADERS:
|
||||||
if header in request.META:
|
if header in request.META:
|
||||||
client_ip = request.META[header].split(',')[0].partition(':')[0]
|
ip = request.META[header].split(',')[0].strip()
|
||||||
try:
|
try:
|
||||||
return IPAddress(client_ip)
|
return IPAddress(ip)
|
||||||
except ValueError:
|
except AddrFormatError:
|
||||||
raise ValueError(f"Invalid IP address set for {header}: {client_ip}")
|
# Parse the string with urlparse() to remove port number or any other cruft
|
||||||
|
ip = urlparse(f'//{ip}').hostname
|
||||||
|
|
||||||
|
try:
|
||||||
|
return IPAddress(ip)
|
||||||
|
except AddrFormatError:
|
||||||
|
# We did our best
|
||||||
|
raise ValueError(f"Invalid IP address set for {header}: {ip}")
|
||||||
|
|
||||||
# Could not determine the client IP address from request headers
|
# Could not determine the client IP address from request headers
|
||||||
return None
|
return None
|
||||||
|
@ -1,3 +1,3 @@
|
|||||||
<a class="btn btn-sm {{ color }} copy-content" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
<a class="btn btn-sm {{ color }} copy-content {{ classes }}" data-clipboard-target="{{ target }}" title="Copy to clipboard">
|
||||||
<i class="mdi mdi-content-copy"></i>
|
<i class="mdi mdi-content-copy"></i>
|
||||||
</a>
|
</a>
|
||||||
|
@ -87,13 +87,14 @@ def checkmark(value, show_false=True, true='Yes', false='No'):
|
|||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag('builtins/copy_content.html')
|
@register.inclusion_tag('builtins/copy_content.html')
|
||||||
def copy_content(target, prefix=None, color='primary'):
|
def copy_content(target, prefix=None, color='primary', classes=None):
|
||||||
"""
|
"""
|
||||||
Display a copy button to copy the content of a field.
|
Display a copy button to copy the content of a field.
|
||||||
"""
|
"""
|
||||||
return {
|
return {
|
||||||
'target': f'#{prefix or ""}{target}',
|
'target': f'#{prefix or ""}{target}',
|
||||||
'color': f'btn-{color}'
|
'color': f'btn-{color}',
|
||||||
|
'classes': classes or '',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
28
netbox/utilities/tests/test_request.py
Normal file
28
netbox/utilities/tests/test_request.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from django.test import TestCase, RequestFactory
|
||||||
|
|
||||||
|
from netaddr import IPAddress
|
||||||
|
from utilities.request import get_client_ip
|
||||||
|
|
||||||
|
|
||||||
|
class GetClientIPTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
|
||||||
|
def test_ipv4_address(self):
|
||||||
|
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1')
|
||||||
|
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
|
||||||
|
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1:8080')
|
||||||
|
self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1'))
|
||||||
|
|
||||||
|
def test_ipv6_address(self):
|
||||||
|
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='2001:db8::8a2e:370:7334')
|
||||||
|
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||||
|
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]')
|
||||||
|
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||||
|
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]:8080')
|
||||||
|
self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334'))
|
||||||
|
|
||||||
|
def test_invalid_ip_address(self):
|
||||||
|
request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip')
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
get_client_ip(request)
|
@ -46,6 +46,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
(_('Tenant'), ('tenant_group_id', 'tenant_id')),
|
||||||
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
(_('Contacts'), ('contact', 'contact_role', 'contact_group')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'group_id')
|
||||||
type_id = DynamicModelMultipleChoiceField(
|
type_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=ClusterType.objects.all(),
|
queryset=ClusterType.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
@ -188,6 +189,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm):
|
|||||||
(_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')),
|
(_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')),
|
||||||
(_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
|
(_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')),
|
||||||
)
|
)
|
||||||
|
selector_fields = ('filter_id', 'q', 'virtual_machine_id')
|
||||||
cluster_id = DynamicModelMultipleChoiceField(
|
cluster_id = DynamicModelMultipleChoiceField(
|
||||||
queryset=Cluster.objects.all(),
|
queryset=Cluster.objects.all(),
|
||||||
required=False,
|
required=False,
|
||||||
|
@ -441,7 +441,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
|
|||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = L2VPNTermination
|
model = L2VPNTermination
|
||||||
fields = ('l2vpn', )
|
fields = ('l2vpn', 'tags')
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
def __init__(self, *args, **kwargs):
|
||||||
instance = kwargs.get('instance')
|
instance = kwargs.get('instance')
|
||||||
|
@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable):
|
|||||||
orderable=False,
|
orderable=False,
|
||||||
verbose_name=_('Object Site')
|
verbose_name=_('Object Site')
|
||||||
)
|
)
|
||||||
|
tags = columns.TagColumn(
|
||||||
|
url_name='ipam:l2vpntermination_list'
|
||||||
|
)
|
||||||
|
|
||||||
class Meta(NetBoxTable.Meta):
|
class Meta(NetBoxTable.Meta):
|
||||||
model = L2VPNTermination
|
model = L2VPNTermination
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
|
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site',
|
||||||
'actions',
|
'tags', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',
|
'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions',
|
||||||
|
@ -1,8 +1,8 @@
|
|||||||
bleach==6.1.0
|
bleach==6.1.0
|
||||||
Django==4.2.7
|
Django==4.2.8
|
||||||
django-cors-headers==4.3.1
|
django-cors-headers==4.3.1
|
||||||
django-debug-toolbar==4.2.0
|
django-debug-toolbar==4.2.0
|
||||||
django-filter==23.4
|
django-filter==23.5
|
||||||
django-graphiql-debug-toolbar==0.2.0
|
django-graphiql-debug-toolbar==0.2.0
|
||||||
django-mptt==0.14.0
|
django-mptt==0.14.0
|
||||||
django-pglocks==1.0.4
|
django-pglocks==1.0.4
|
||||||
@ -10,22 +10,22 @@ django-prometheus==2.3.1
|
|||||||
django-redis==5.4.0
|
django-redis==5.4.0
|
||||||
django-rich==1.8.0
|
django-rich==1.8.0
|
||||||
django-rq==2.9.0
|
django-rq==2.9.0
|
||||||
django-tables2==2.6.0
|
|
||||||
django-taggit==5.0.1
|
django-taggit==5.0.1
|
||||||
|
django-tables2==2.7.0
|
||||||
django-timezone-field==6.1.0
|
django-timezone-field==6.1.0
|
||||||
djangorestframework==3.14.0
|
djangorestframework==3.14.0
|
||||||
drf-spectacular==0.26.5
|
drf-spectacular==0.27.0
|
||||||
drf-spectacular-sidecar==2023.10.1
|
drf-spectacular-sidecar==2023.12.1
|
||||||
feedparser==6.0.10
|
feedparser==6.0.11
|
||||||
graphene-django==3.0.0
|
graphene-django==3.0.0
|
||||||
gunicorn==21.2.0
|
gunicorn==21.2.0
|
||||||
Jinja2==3.1.2
|
Jinja2==3.1.2
|
||||||
Markdown==3.5.1
|
Markdown==3.5.1
|
||||||
mkdocs-material==9.4.14
|
mkdocs-material==9.5.2
|
||||||
mkdocstrings[python-legacy]==0.24.0
|
mkdocstrings[python-legacy]==0.24.0
|
||||||
netaddr==0.9.0
|
netaddr==0.9.0
|
||||||
Pillow==10.1.0
|
Pillow==10.1.0
|
||||||
psycopg[binary,pool]==3.1.13
|
psycopg[binary,pool]==3.1.15
|
||||||
PyYAML==6.0.1
|
PyYAML==6.0.1
|
||||||
requests==2.31.0
|
requests==2.31.0
|
||||||
social-auth-app-django==5.4.0
|
social-auth-app-django==5.4.0
|
||||||
|
Loading…
Reference in New Issue
Block a user