Merge branch 'develop' into 11508-azure-group

This commit is contained in:
Arthur 2023-08-08 16:37:06 +07:00
commit 44cd3a33c2
38 changed files with 373 additions and 136 deletions

View File

@ -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.5.6 placeholder: v3.5.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -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.5.6 placeholder: v3.5.7
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,12 +14,25 @@
</div> </div>
<h3></h3> <h3></h3>
Some general tips for engaging here on GitHub: ## :information_source: Welcome to the Stadium!
In her book [Working in Public](https://www.amazon.com/Working-Public-Making-Maintenance-Software/dp/0578675862), Nadia Eghbal defines four production models for open source projects, categorized by contributor and user growth: federations, clubs, toys, and stadiums. The NetBox project fits her definition of a stadium very well:
> Stadiums are projects with low contributor growth and high user growth. While they may receive casual contributions, their regular contributor base does not grow proportionately to their users. As a result, they tend to be powered by one or a few developers.
The bulk of NetBox's development is carried out by a handful of core maintainers, with occasional contributions from collaborators in the community. We find the stadium analogy very useful in conveying the roles and obligations of both contributors and users.
If you're a contributor, actively working on the center stage, you have an obligation to produce quality content that will benefit the project as a whole. Conversely, if you're in the audience consuming the work being produced, you have the option of making requests and suggestions, but must also recognize that contributors are under no obligation to act on them.
NetBox users are welcome to participate in either role, on stage or in the crowd. We ask only that you acknowledge the role you've chosen and respect the roles of others.
### General Tips for Working on GitHub
* Register for a free [GitHub account](https://github.com/signup) if you haven't already. * Register for a free [GitHub account](https://github.com/signup) if you haven't already.
* You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images. * You can use [GitHub Markdown](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax) for formatting text and adding images.
* To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.) * To help mitigate notification spam, please avoid "bumping" issues with no activity. (To vote an issue up or down, use a :thumbsup: or :thumbsdown: reaction.)
* Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue. * Please avoid pinging members with `@` unless they've previously expressed interest or involvement with that particular issue.
* Familiarize yourself with [this list of discussion anti-patterns](https://github.com/bradfitz/issue-tracker-behaviors) and make every effort to avoid them.
## :bug: Reporting Bugs ## :bug: Reporting Bugs

View File

@ -43,10 +43,22 @@ Follow these instructions to perform a new installation of NetBox in a temporary
Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below. Submit a pull request to merge the `feature` branch into the `develop` branch in preparation for its release. Once it has been merged, continue with the section for patch releases below.
### Rebuild Demo Data (After Release)
After the release of a new minor version, generate a new demo data snapshot compatible with the new release. See the [`netbox-demo-data`](https://github.com/netbox-community/netbox-demo-data) repository for instructions.
--- ---
## Patch Releases ## Patch Releases
### Notify netbox-docker Project of Any Relevant Changes
Notify the [`netbox-docker`](https://github.com/netbox-community/netbox-docker) maintainers (in **#netbox-docker**) of any changes that may be relevant to their build process, including:
* Significant changes to `upgrade.sh`
* Increases in minimum versions for service dependencies (PostgreSQL, Redis, etc.)
* Any changes to the reference installation
### Update Requirements ### Update Requirements
Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this: Before each release, update each of NetBox's Python dependencies to its most recent stable version. These are defined in `requirements.txt`, which is updated from `base_requirements.txt` using `pip`. To do this:

View File

@ -48,36 +48,40 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
Download and extract the latest version: Download and extract the latest version:
```no-highlight ```no-highlight
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz # Set $NEWVER to the NetBox version being installed
sudo tar -xzf vX.Y.Z.tar.gz -C /opt NEWVER=3.5.0
sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox wget https://github.com/netbox-community/netbox/archive/v$NEWVER.tar.gz
sudo tar -xzf v$NEWVER.tar.gz -C /opt
sudo ln -sfn /opt/netbox-$NEWVER/ /opt/netbox
``` ```
Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version: Copy `local_requirements.txt`, `configuration.py`, and `ldap_config.py` (if present) from the current installation to the new version:
```no-highlight ```no-highlight
sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/ # Set $OLDVER to the NetBox version currently installed
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/ NEWVER=3.4.9
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/ sudo cp /opt/netbox-$OLDVER/local_requirements.txt /opt/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
sudo cp /opt/netbox-$OLDVER/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
``` ```
Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.) Be sure to replicate your uploaded media as well. (The exact action necessary will depend on where you choose to store your media, but in general moving or copying the media directory will suffice.)
```no-highlight ```no-highlight
sudo cp -pr /opt/netbox-X.Y.Z/netbox/media/ /opt/netbox/netbox/ sudo cp -pr /opt/netbox-$OLDVER/netbox/media/ /opt/netbox/netbox/
``` ```
Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.) Also make sure to copy or link any custom scripts and reports that you've made. Note that if these are stored outside the project root, you will not need to copy them. (Check the `SCRIPTS_ROOT` and `REPORTS_ROOT` parameters in the configuration file above if you're unsure.)
```no-highlight ```no-highlight
sudo cp -r /opt/netbox-X.Y.Z/netbox/scripts /opt/netbox/netbox/ sudo cp -r /opt/netbox-$OLDVER/netbox/scripts /opt/netbox/netbox/
sudo cp -r /opt/netbox-X.Y.Z/netbox/reports /opt/netbox/netbox/ sudo cp -r /opt/netbox-$OLDVER/netbox/reports /opt/netbox/netbox/
``` ```
If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well: If you followed the original installation guide to set up gunicorn, be sure to copy its configuration as well:
```no-highlight ```no-highlight
sudo cp /opt/netbox-X.Y.Z/gunicorn.py /opt/netbox/ sudo cp /opt/netbox-$OLDVER/gunicorn.py /opt/netbox/
``` ```
### Option B: Clone the Git Repository ### Option B: Clone the Git Repository

View File

@ -1,11 +1,30 @@
# NetBox v3.5 # NetBox v3.5
## v3.5.7 (FUTURE) ## v3.5.8 (FUTURE)
### Enhancements ### Enhancements
* [#12889](https://github.com/netbox-community/netbox/issues/12889) - Add 400GE CFP2 interface type
* [#13033](https://github.com/netbox-community/netbox/issues/13033) - Add human-friendly speed column to interfaces table
* [#13151](https://github.com/netbox-community/netbox/issues/13151) - Add "assigned" filter for IP addresses
### Bug Fixes
* [#12750](https://github.com/netbox-community/netbox/issues/12750) - Automatically delete an AutoSyncRecord when its object is deleted
* [#13343](https://github.com/netbox-community/netbox/issues/13343) - Fix filtering of circuits under provider network view
* [#13369](https://github.com/netbox-community/netbox/issues/13369) - Fix job termination status for failed reports
---
## v3.5.7 (2023-07-28)
### Enhancements
* [#11803](https://github.com/netbox-community/netbox/issues/11803) - Move non-rack devices list to a separate tab under the rack view
* [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source * [#12625](https://github.com/netbox-community/netbox/issues/12625) - Mask sensitive parameters when viewing a configured data source
* [#13009](https://github.com/netbox-community/netbox/issues/13009) - Add IEC 10609-1 and NBR 14136 power port & outlet types
* [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results * [#13097](https://github.com/netbox-community/netbox/issues/13097) - Implement a faster initial poll for report & script results
* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types
### Bug Fixes ### Bug Fixes
@ -13,6 +32,7 @@
* [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API * [#13167](https://github.com/netbox-community/netbox/issues/13167) - Fix missing script results when fetched via REST API
* [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces * [#13233](https://github.com/netbox-community/netbox/issues/13233) - Remove extraneous VLAN group field from bulk edit form for interfaces
* [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false * [#13237](https://github.com/netbox-community/netbox/issues/13237) - Permit unauthenticated access to content types REST API endpoint when `LOGIN_REQUIRED` is false
* [#13285](https://github.com/netbox-community/netbox/issues/13285) - Fix exception when importing device type missing rack unit height value
--- ---

View File

@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
related_models = ( related_models = (
( (
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance), Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
'providernetwork_id', 'provider_network_id',
), ),
) )

View File

@ -318,6 +318,10 @@ class PowerPortTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h' TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking # NEMA non-locking
TYPE_NEMA_115P = 'nema-1-15p' TYPE_NEMA_115P = 'nema-1-15p'
TYPE_NEMA_515P = 'nema-5-15p' TYPE_NEMA_515P = 'nema-5-15p'
@ -429,6 +433,11 @@ class PowerPortTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'), (TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)), )),
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
('NEMA (Non-locking)', ( ('NEMA (Non-locking)', (
(TYPE_NEMA_115P, 'NEMA 1-15P'), (TYPE_NEMA_115P, 'NEMA 1-15P'),
(TYPE_NEMA_515P, 'NEMA 5-15P'), (TYPE_NEMA_515P, 'NEMA 5-15P'),
@ -553,6 +562,10 @@ class PowerOutletTypeChoices(ChoiceSet):
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h' TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h' TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h' TYPE_IEC_3PNE9H = 'iec-60309-3p-n-e-9h'
# IEC 60906-1
TYPE_IEC_60906_1 = 'iec-60906-1'
TYPE_NBR_14136_10A = 'nbr-14136-10a'
TYPE_NBR_14136_20A = 'nbr-14136-20a'
# NEMA non-locking # NEMA non-locking
TYPE_NEMA_115R = 'nema-1-15r' TYPE_NEMA_115R = 'nema-1-15r'
TYPE_NEMA_515R = 'nema-5-15r' TYPE_NEMA_515R = 'nema-5-15r'
@ -657,6 +670,11 @@ class PowerOutletTypeChoices(ChoiceSet):
(TYPE_IEC_3PNE6H, '3P+N+E 6H'), (TYPE_IEC_3PNE6H, '3P+N+E 6H'),
(TYPE_IEC_3PNE9H, '3P+N+E 9H'), (TYPE_IEC_3PNE9H, '3P+N+E 9H'),
)), )),
('IEC 60906-1', (
(TYPE_IEC_60906_1, 'IEC 60906-1'),
(TYPE_NBR_14136_10A, '2P+T 10A (NBR 14136)'),
(TYPE_NBR_14136_20A, '2P+T 20A (NBR 14136)'),
)),
('NEMA (Non-locking)', ( ('NEMA (Non-locking)', (
(TYPE_NEMA_115R, 'NEMA 1-15R'), (TYPE_NEMA_115R, 'NEMA 1-15R'),
(TYPE_NEMA_515R, 'NEMA 5-15R'), (TYPE_NEMA_515R, 'NEMA 5-15R'),
@ -816,6 +834,7 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_CFP2 = '200gbase-x-cfp2'
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
TYPE_400GE_OSFP = '400gbase-x-osfp' TYPE_400GE_OSFP = '400gbase-x-osfp'
TYPE_400GE_CDFP = '400gbase-x-cdfp' TYPE_400GE_CDFP = '400gbase-x-cdfp'
@ -958,6 +977,7 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_100GE_CFP, 'CFP (100GE)'), (TYPE_100GE_CFP, 'CFP (100GE)'),
(TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_100GE_CFP2, 'CFP2 (100GE)'),
(TYPE_200GE_CFP2, 'CFP2 (200GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'),
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
(TYPE_100GE_CFP4, 'CFP4 (100GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'),
(TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CXP, 'CXP (100GE)'),
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),

View File

@ -1042,6 +1042,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
queryset=VirtualDeviceContext.objects.all(), queryset=VirtualDeviceContext.objects.all(),
required=False, required=False,
label='Virtual Device Contexts', label='Virtual Device Contexts',
initial_params={
'interfaces': '$parent',
},
query_params={ query_params={
'device_id': '$device', 'device_id': '$device',
} }

View File

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

View File

@ -232,7 +232,7 @@ class DeviceType(PrimaryModel, WeightMixin):
super().clean() super().clean()
# U height must be divisible by 0.5 # U height must be divisible by 0.5
if self.u_height % decimal.Decimal(0.5): if decimal.Decimal(self.u_height) % decimal.Decimal(0.5):
raise ValidationError({ raise ValidationError({
'u_height': "U height must be in increments of 0.5 rack units." 'u_height': "U height must be in increments of 0.5 rack units."
}) })

View File

@ -545,6 +545,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
} }
) )
mgmt_only = columns.BooleanColumn() mgmt_only = columns.BooleanColumn()
speed_formatted = columns.TemplateColumn(
template_code='{% load helpers %}{{ value|humanize_speed }}',
accessor=Accessor('speed'),
verbose_name='Speed'
)
wireless_link = tables.Column( wireless_link = tables.Column(
linkify=True linkify=True
) )
@ -568,7 +573,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
model = models.Interface model = models.Interface
fields = ( fields = (
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel',
'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable',
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',

View File

@ -681,13 +681,6 @@ class RackView(generic.ObjectView):
(PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'), (PowerFeed.objects.restrict(request.user).filter(rack=instance), 'rack_id'),
) )
# Get 0U devices located within the rack
nonracked_devices = Device.objects.filter(
rack=instance,
position__isnull=True,
parent_bay__isnull=True
).prefetch_related('device_type__manufacturer', 'parent_bay', 'device_role')
peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site) peer_racks = Rack.objects.restrict(request.user, 'view').filter(site=instance.site)
if instance.location: if instance.location:
@ -704,7 +697,6 @@ class RackView(generic.ObjectView):
return { return {
'related_models': related_models, 'related_models': related_models,
'nonracked_devices': nonracked_devices,
'next_rack': next_rack, 'next_rack': next_rack,
'prev_rack': prev_rack, 'prev_rack': prev_rack,
'svg_extra': svg_extra, 'svg_extra': svg_extra,
@ -731,6 +723,26 @@ class RackRackReservationsView(generic.ObjectChildrenView):
return parent.reservations.restrict(request.user, 'view') return parent.reservations.restrict(request.user, 'view')
@register_model_view(Rack, 'nonracked_devices', 'nonracked-devices')
class RackNonRackedView(generic.ObjectChildrenView):
queryset = Rack.objects.all()
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab(
label=_('Non-Racked Devices'),
badge=lambda obj: obj.devices.filter(rack=obj, position__isnull=True, parent_bay__isnull=True).count(),
weight=500,
permission='dcim.view_device',
)
def get_children(self, request, parent):
return parent.devices.restrict(request.user, 'view').filter(
rack=parent, position__isnull=True, parent_bay__isnull=True
)
@register_model_view(Rack, 'edit') @register_model_view(Rack, 'edit')
class RackEditView(generic.ObjectEditView): class RackEditView(generic.ObjectEditView):
queryset = Rack.objects.all() queryset = Rack.objects.all()

View File

@ -2,7 +2,6 @@ import collections
from importlib import import_module from importlib import import_module
from django.apps import AppConfig from django.apps import AppConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
from packaging import version from packaging import version
@ -146,23 +145,3 @@ class PluginConfig(AppConfig):
for setting, value in cls.default_settings.items(): for setting, value in cls.default_settings.items():
if setting not in user_config: if setting not in user_config:
user_config[setting] = value user_config[setting] = value
#
# Utilities
#
def get_plugin_config(plugin_name, parameter, default=None):
"""
Return the value of the specified plugin configuration parameter.
Args:
plugin_name: The name of the plugin
parameter: The name of the configuration parameter
default: The value to return if the parameter is not defined (default: None)
"""
try:
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
return plugin_config.get(parameter, default)
except KeyError:
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

View File

@ -0,0 +1,37 @@
from django.apps import apps
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
__all__ = (
'get_installed_plugins',
'get_plugin_config',
)
def get_installed_plugins():
"""
Return a dictionary mapping the names of installed plugins to their versions.
"""
plugins = {}
for plugin_name in settings.PLUGINS:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
plugins[plugin_name] = getattr(plugin_config, 'version', None)
return dict(sorted(plugins.items()))
def get_plugin_config(plugin_name, parameter, default=None):
"""
Return the value of the specified plugin configuration parameter.
Args:
plugin_name: The name of the plugin
parameter: The name of the configuration parameter
default: The value to return if the parameter is not defined (default: None)
"""
try:
plugin_config = settings.PLUGINS_CONFIG[plugin_name]
return plugin_config.get(parameter, default)
except KeyError:
raise ImproperlyConfigured(f"Plugin {plugin_name} is not registered.")

View File

@ -214,20 +214,18 @@ class Report(object):
self.active_test = method_name self.active_test = method_name
test_method = getattr(self, method_name) test_method = getattr(self, method_name)
test_method() test_method()
job.data = self._results
if self.failed: if self.failed:
self.logger.warning("Report failed") self.logger.warning("Report failed")
job.status = JobStatusChoices.STATUS_FAILED job.terminate(status=JobStatusChoices.STATUS_FAILED)
else: else:
self.logger.info("Report completed successfully") self.logger.info("Report completed successfully")
job.status = JobStatusChoices.STATUS_COMPLETED job.terminate()
except Exception as e: except Exception as e:
stacktrace = traceback.format_exc() stacktrace = traceback.format_exc()
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>") self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
logger.error(f"Exception raised during report execution: {e}") logger.error(f"Exception raised during report execution: {e}")
job.terminate(status=JobStatusChoices.STATUS_ERRORED) job.terminate(status=JobStatusChoices.STATUS_ERRORED)
finally:
job.data = self._results
job.terminate()
# Perform any post-run tasks # Perform any post-run tasks
self.post_run() self.post_run()

View File

@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
from django.test import Client, TestCase, override_settings from django.test import Client, TestCase, override_settings
from django.urls import reverse from django.urls import reverse
from extras.plugins import PluginMenu, get_plugin_config from extras.plugins import PluginMenu
from extras.tests.dummy_plugin import config as dummy_config from extras.tests.dummy_plugin import config as dummy_config
from extras.plugins.utils import get_plugin_config
from netbox.graphql.schema import Query from netbox.graphql.schema import Query
from netbox.registry import registry from netbox.registry import registry

View File

@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
def setUpTestData(cls): def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site) site_ct = ContentType.objects.get_for_model(Site)
DUMMY_URL = "http://localhost/" DUMMY_URL = 'http://localhost:9000/'
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
webhooks = Webhook.objects.bulk_create(( webhooks = Webhook.objects.bulk_create((
Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'), Webhook(name='Webhook 1', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
@ -259,7 +259,7 @@ class WebhookTest(APITestCase):
name='Conditional Webhook', name='Conditional Webhook',
type_create=True, type_create=True,
type_update=True, type_update=True,
payload_url='http://localhost/', payload_url='http://localhost:9000/',
conditions={ conditions={
'and': [ 'and': [
{ {

View File

@ -591,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
method='_assigned_to_interface', method='_assigned_to_interface',
label=_('Is assigned to an interface'), label=_('Is assigned to an interface'),
) )
assigned = django_filters.BooleanFilter(
method='_assigned',
label=_('Is assigned'),
)
status = django_filters.MultipleChoiceFilter( status = django_filters.MultipleChoiceFilter(
choices=IPAddressStatusChoices, choices=IPAddressStatusChoices,
null_value=None null_value=None
@ -706,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
assigned_object_id__isnull=False assigned_object_id__isnull=False
) )
def _assigned(self, queryset, name, value):
if value:
return queryset.exclude(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
else:
return queryset.filter(
assigned_object_type__isnull=True,
assigned_object_id__isnull=True
)
class FHRPGroupFilterSet(NetBoxModelFilterSet): class FHRPGroupFilterSet(NetBoxModelFilterSet):
protocol = django_filters.MultipleChoiceFilter( protocol = django_filters.MultipleChoiceFilter(

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
@ -10,7 +9,9 @@ from ipam.constants import *
from ipam.models import * from ipam.models import *
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, SlugField from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
)
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
__all__ = ( __all__ = (
@ -41,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm):
to_field_name='name', to_field_name='name',
help_text=_('Assigned tenant') help_text=_('Assigned tenant')
) )
import_targets = CSVModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
to_field_name='name',
help_text=_('Import route targets')
)
export_targets = CSVModelMultipleChoiceField(
queryset=RouteTarget.objects.all(),
required=False,
to_field_name='name',
help_text=_('Export route targets')
)
class Meta: class Meta:
model = VRF model = VRF
fields = ('name', 'rd', 'tenant', 'enforce_unique', 'description', 'comments', 'tags') fields = (
'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'comments',
'tags',
)
class RouteTargetImportForm(NetBoxModelImportForm): class RouteTargetImportForm(NetBoxModelImportForm):

View File

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

View File

@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]} params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_assigned(self):
params = {'assigned': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
params = {'assigned': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_assigned_to_interface(self): def test_assigned_to_interface(self):
params = {'assigned_to_interface': 'true'} params = {'assigned_to_interface': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)

View File

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

View File

@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
from rest_framework.views import APIView from rest_framework.views import APIView
from rq.worker import Worker from rq.worker import Worker
from extras.plugins.utils import get_installed_plugins
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
@ -61,19 +62,11 @@ class StatusView(APIView):
installed_apps[app_config.name] = version installed_apps[app_config.name] = version
installed_apps = {k: v for k, v in sorted(installed_apps.items())} installed_apps = {k: v for k, v in sorted(installed_apps.items())}
# Gather installed plugins
plugins = {}
for plugin_name in settings.PLUGINS:
plugin_name = plugin_name.rsplit('.', 1)[-1]
plugin_config = apps.get_app_config(plugin_name)
plugins[plugin_name] = getattr(plugin_config, 'version', None)
plugins = {k: v for k, v in sorted(plugins.items())}
return Response({ return Response({
'django-version': DJANGO_VERSION, 'django-version': DJANGO_VERSION,
'installed-apps': installed_apps, 'installed-apps': installed_apps,
'netbox-version': settings.VERSION, 'netbox-version': settings.VERSION,
'plugins': plugins, 'plugins': get_installed_plugins(),
'python-version': platform.python_version(), 'python-version': platform.python_version(),
'rq-workers-running': Worker.count(get_connection('default')), 'rq-workers-running': Worker.count(get_connection('default')),
}) })

View File

@ -442,6 +442,19 @@ class SyncedDataMixin(models.Model):
return ret return ret
def delete(self, *args, **kwargs):
from core.models import AutoSyncRecord
# Delete AutoSyncRecord
content_type = ContentType.objects.get_for_model(self)
AutoSyncRecord.objects.filter(
datafile=self.data_file,
object_type=content_type,
object_id=self.pk
).delete()
return super().delete(*args, **kwargs)
def resolve_data_file(self): def resolve_data_file(self):
""" """
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if

View File

@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu(
get_model_item('tenancy', 'contact', _('Contacts')), get_model_item('tenancy', 'contact', _('Contacts')),
get_model_item('tenancy', 'contactgroup', _('Contact Groups')), get_model_item('tenancy', 'contactgroup', _('Contact Groups')),
get_model_item('tenancy', 'contactrole', _('Contact Roles')), get_model_item('tenancy', 'contactrole', _('Contact Roles')),
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]), get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']),
), ),
), ),
), ),

View File

@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
# Environment setup # Environment setup
# #
VERSION = '3.5.7-dev' VERSION = '3.5.8-dev'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -461,8 +461,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
TEST_RUNNER = "django_rich.test.RichRunner"
# Exclude potentially sensitive models from wildcard view exemption. These may still be exempted # Exclude potentially sensitive models from wildcard view exemption. These may still be exempted
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter. # by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
EXEMPT_EXCLUDE_MODELS = ( EXEMPT_EXCLUDE_MODELS = (

View File

@ -54,7 +54,7 @@ class BaseTable(tables.Table):
# 3. Meta.fields # 3. Meta.fields
selected_columns = None selected_columns = None
if user is not None and not isinstance(user, AnonymousUser): if user is not None and not isinstance(user, AnonymousUser):
selected_columns = user.config.get(f"tables.{self.__class__.__name__}.columns") selected_columns = user.config.get(f"tables.{self.name}.columns")
if not selected_columns: if not selected_columns:
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields) selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
@ -113,6 +113,10 @@ class BaseTable(tables.Table):
columns.append((name, column.verbose_name)) columns.append((name, column.verbose_name))
return columns return columns
@property
def name(self):
return self.__class__.__name__
@property @property
def available_columns(self): def available_columns(self):
return self._get_columns(visible=False) return self._get_columns(visible=False)
@ -138,17 +142,16 @@ class BaseTable(tables.Table):
""" """
# Save ordering preference # Save ordering preference
if request.user.is_authenticated: if request.user.is_authenticated:
table_name = self.__class__.__name__
if self.prefixed_order_by_field in request.GET: if self.prefixed_order_by_field in request.GET:
if request.GET[self.prefixed_order_by_field]: if request.GET[self.prefixed_order_by_field]:
# If an ordering has been specified as a query parameter, save it as the # If an ordering has been specified as a query parameter, save it as the
# user's preferred ordering for this table. # user's preferred ordering for this table.
ordering = request.GET.getlist(self.prefixed_order_by_field) ordering = request.GET.getlist(self.prefixed_order_by_field)
request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) request.user.config.set(f'tables.{self.name}.ordering', ordering, commit=True)
else: else:
# If the ordering has been set to none (empty), clear any existing preference. # If the ordering has been set to none (empty), clear any existing preference.
request.user.config.clear(f'tables.{table_name}.ordering', commit=True) request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
# If no ordering has been specified, set the preferred ordering (if any). # If no ordering has been specified, set the preferred ordering (if any).
self.order_by = ordering self.order_by = ordering

View File

@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
from django.views.generic import View from django.views.generic import View
from sentry_sdk import capture_message from sentry_sdk import capture_message
from extras.plugins.utils import get_installed_plugins
__all__ = ( __all__ = (
'handler_404', 'handler_404',
'handler_500', 'handler_500',
@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
'exception': str(type_), 'exception': str(type_),
'netbox_version': settings.VERSION, 'netbox_version': settings.VERSION,
'python_version': platform.python_version(), 'python_version': platform.python_version(),
'plugins': get_installed_plugins(),
})) }))

View File

@ -30,7 +30,10 @@
{{ error }} {{ error }}
Python version: {{ python_version }} Python version: {{ python_version }}
NetBox version: {{ netbox_version }}</pre> NetBox version: {{ netbox_version }}
Plugins: {% for plugin, version in plugins.items %}
{{ plugin }}: {{ version }}{% empty %}None installed{% endfor %}
</pre>
<p> <p>
If further assistance is required, please post to the <a href="https://github.com/netbox-community/netbox/discussions">NetBox discussion forum</a> on GitHub. If further assistance is required, please post to the <a href="https://github.com/netbox-community/netbox/discussions">NetBox discussion forum</a> on GitHub.
</p> </p>

View File

@ -190,7 +190,6 @@
</div> </div>
</div> </div>
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/related_objects.html' %}
{% include 'dcim/inc/nonracked_devices.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,51 @@
{% extends 'dcim/rack/base.html' %}
{% load helpers %}
{% block extra_controls %}
{% if perms.dcim.add_device %}
<div class="bulk-button-group">
<a href="{% url 'dcim:device_add' %}?rack={{ object.pk }}&site={{ object.site.pk }}&return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add non-racked device
</a>
</div>
{% endif %}
{% endblock %}
{% block content %}
{% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
<form method="post">
{% csrf_token %}
<div class="card">
<div class="card-body htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if 'bulk_edit' in actions %}
<button type="submit" name="_edit"
formaction="{% url 'dcim:device_bulk_edit' %}?return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
</button>
{% endif %}
{% if 'bulk_delete' in actions %}
<button type="submit"
formaction="{% url 'dcim:device_bulk_delete' %}?return_url={% url 'dcim:rack_nonracked_devices' pk=object.pk %}"
class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
</button>
{% endif %}
</div>
</div>
</form>
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -1,9 +1,11 @@
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from netbox.forms import NetBoxModelImportForm from netbox.forms import NetBoxModelImportForm
from tenancy.models import * from tenancy.models import *
from utilities.forms.fields import CSVModelChoiceField, SlugField from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField
__all__ = ( __all__ = (
'ContactAssignmentImportForm',
'ContactImportForm', 'ContactImportForm',
'ContactGroupImportForm', 'ContactGroupImportForm',
'ContactRoleImportForm', 'ContactRoleImportForm',
@ -81,3 +83,27 @@ class ContactImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = Contact model = Contact
fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags') fields = ('name', 'title', 'phone', 'email', 'address', 'link', 'group', 'description', 'comments', 'tags')
class ContactAssignmentImportForm(NetBoxModelImportForm):
content_type = CSVContentTypeField(
queryset=ContentType.objects.all(),
help_text=_("One or more assigned object types")
)
contact = CSVModelChoiceField(
queryset=Contact.objects.all(),
to_field_name='name',
help_text=_('Assigned contact')
)
role = CSVModelChoiceField(
queryset=ContactRole.objects.all(),
to_field_name='name',
help_text=_('Assigned role')
)
# Remove the tags field added by NetBoxModelImportForm (unsupported by ContactAssignment)
tags = None
class Meta:
model = ContactAssignment
fields = ('content_type', 'object_id', 'contact', 'priority', 'role')

View File

@ -49,6 +49,7 @@ urlpatterns = [
# Contact assignments # Contact assignments
path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'), path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'),
path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'), path('contact-assignments/add/', views.ContactAssignmentEditView.as_view(), name='contactassignment_add'),
path('contact-assignments/import/', views.ContactAssignmentBulkImportView.as_view(), name='contactassignment_import'),
path('contact-assignments/edit/', views.ContactAssignmentBulkEditView.as_view(), name='contactassignment_bulk_edit'), path('contact-assignments/edit/', views.ContactAssignmentBulkEditView.as_view(), name='contactassignment_bulk_edit'),
path('contact-assignments/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'), path('contact-assignments/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'),
path('contact-assignments/<int:pk>/', include(get_model_urls('tenancy', 'contactassignment'))), path('contact-assignments/<int:pk>/', include(get_model_urls('tenancy', 'contactassignment'))),

View File

@ -420,6 +420,11 @@ class ContactAssignmentBulkEditView(generic.BulkEditView):
form = forms.ContactAssignmentBulkEditForm form = forms.ContactAssignmentBulkEditForm
class ContactAssignmentBulkImportView(generic.BulkImportView):
queryset = ContactAssignment.objects.all()
model_form = forms.ContactAssignmentImportForm
class ContactAssignmentBulkDeleteView(generic.BulkDeleteView): class ContactAssignmentBulkDeleteView(generic.BulkDeleteView):
queryset = ContactAssignment.objects.all() queryset = ContactAssignment.objects.all()
filterset = filtersets.ContactAssignmentFilterSet filterset = filtersets.ContactAssignmentFilterSet

View File

@ -123,9 +123,9 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
records = [] records = []
try: try:
for data in yaml.load_all(data, Loader=yaml.SafeLoader): for data in yaml.load_all(data, Loader=yaml.SafeLoader):
if type(data) == list: if type(data) is list:
records.extend(data) records.extend(data)
elif type(data) == dict: elif type(data) is dict:
records.append(data) records.append(data)
else: else:
raise forms.ValidationError({ raise forms.ValidationError({

View File

@ -114,7 +114,7 @@ def annotated_date(date_value):
if not date_value: if not date_value:
return '' return ''
if type(date_value) == datetime.date: if type(date_value) is datetime.date:
long_ts = date(date_value, 'DATE_FORMAT') long_ts = date(date_value, 'DATE_FORMAT')
short_ts = date(date_value, 'SHORT_DATE_FORMAT') short_ts = date(date_value, 'SHORT_DATE_FORMAT')
else: else:

View File

@ -1,5 +1,5 @@
bleach==6.0.0 bleach==6.0.0
boto3==1.28.1 boto3==1.28.14
Django==4.1.10 Django==4.1.10
django-cors-headers==4.2.0 django-cors-headers==4.2.0
django-debug-toolbar==4.1.0 django-debug-toolbar==4.1.0
@ -15,21 +15,21 @@ django-tables2==2.6.0
django-taggit==4.0.0 django-taggit==4.0.0
django-timezone-field==5.1 django-timezone-field==5.1
djangorestframework==3.14.0 djangorestframework==3.14.0
drf-spectacular==0.26.3 drf-spectacular==0.26.4
drf-spectacular-sidecar==2023.7.1 drf-spectacular-sidecar==2023.7.1
dulwich==0.21.5 dulwich==0.21.5
feedparser==6.0.10 feedparser==6.0.10
graphene-django==3.0.0 graphene-django==3.0.0
gunicorn==20.1.0 gunicorn==21.2.0
Jinja2==3.1.2 Jinja2==3.1.2
Markdown==3.3.7 Markdown==3.3.7
mkdocs-material==9.1.18 mkdocs-material==9.1.21
mkdocstrings[python-legacy]==0.22.0 mkdocstrings[python-legacy]==0.22.0
netaddr==0.8.0 netaddr==0.8.0
Pillow==10.0.0 Pillow==10.0.0
psycopg2-binary==2.9.6 psycopg2-binary==2.9.6
PyYAML==6.0 PyYAML==6.0.1
sentry-sdk==1.28.0 sentry-sdk==1.28.1
social-auth-app-django==5.2.0 social-auth-app-django==5.2.0
social-auth-core[openidconnect]==4.4.2 social-auth-core[openidconnect]==4.4.2
svgwrite==1.4.3 svgwrite==1.4.3