mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-24 08:25:17 -06:00
Merge branch 'develop' into 11508-azure-group
This commit is contained in:
commit
44cd3a33c2
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.6
|
||||
placeholder: v3.5.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.6
|
||||
placeholder: v3.5.7
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -14,12 +14,25 @@
|
||||
</div>
|
||||
<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.
|
||||
* 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.)
|
||||
* 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
|
||||
|
||||
|
@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
### 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
|
||||
|
||||
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:
|
||||
|
@ -48,36 +48,40 @@ Download the [latest stable release](https://github.com/netbox-community/netbox/
|
||||
Download and extract the latest version:
|
||||
|
||||
```no-highlight
|
||||
wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
|
||||
sudo tar -xzf vX.Y.Z.tar.gz -C /opt
|
||||
sudo ln -sfn /opt/netbox-X.Y.Z/ /opt/netbox
|
||||
# Set $NEWVER to the NetBox version being installed
|
||||
NEWVER=3.5.0
|
||||
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:
|
||||
|
||||
```no-highlight
|
||||
sudo cp /opt/netbox-X.Y.Z/local_requirements.txt /opt/netbox/
|
||||
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/configuration.py /opt/netbox/netbox/netbox/
|
||||
sudo cp /opt/netbox-X.Y.Z/netbox/netbox/ldap_config.py /opt/netbox/netbox/netbox/
|
||||
# Set $OLDVER to the NetBox version currently installed
|
||||
NEWVER=3.4.9
|
||||
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.)
|
||||
|
||||
```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.)
|
||||
|
||||
```no-highlight
|
||||
sudo cp -r /opt/netbox-X.Y.Z/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/scripts /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:
|
||||
|
||||
```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
|
||||
|
@ -1,11 +1,30 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## v3.5.7 (FUTURE)
|
||||
## v3.5.8 (FUTURE)
|
||||
|
||||
### 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
|
||||
* [#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
|
||||
* [#13234](https://github.com/netbox-community/netbox/issues/13234) - Add 100GBASE-X-DSFP and 100GBASE-X-SFPDD interface types
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@ -13,6 +32,7 @@
|
||||
* [#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
|
||||
* [#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
|
||||
|
||||
---
|
||||
|
||||
|
@ -163,7 +163,7 @@ class ProviderNetworkView(generic.ObjectView):
|
||||
related_models = (
|
||||
(
|
||||
Circuit.objects.restrict(request.user, 'view').filter(terminations__provider_network=instance),
|
||||
'providernetwork_id',
|
||||
'provider_network_id',
|
||||
),
|
||||
)
|
||||
|
||||
|
@ -318,6 +318,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
TYPE_IEC_3PNE4H = 'iec-60309-3p-n-e-4h'
|
||||
TYPE_IEC_3PNE6H = 'iec-60309-3p-n-e-6h'
|
||||
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
|
||||
TYPE_NEMA_115P = 'nema-1-15p'
|
||||
TYPE_NEMA_515P = 'nema-5-15p'
|
||||
@ -429,6 +433,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
|
||||
(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)', (
|
||||
(TYPE_NEMA_115P, 'NEMA 1-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_3PNE6H = 'iec-60309-3p-n-e-6h'
|
||||
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
|
||||
TYPE_NEMA_115R = 'nema-1-15r'
|
||||
TYPE_NEMA_515R = 'nema-5-15r'
|
||||
@ -657,6 +670,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
||||
(TYPE_IEC_3PNE6H, '3P+N+E 6H'),
|
||||
(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)', (
|
||||
(TYPE_NEMA_115R, 'NEMA 1-15R'),
|
||||
(TYPE_NEMA_515R, 'NEMA 5-15R'),
|
||||
@ -816,6 +834,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_200GE_CFP2 = '200gbase-x-cfp2'
|
||||
TYPE_200GE_QSFP56 = '200gbase-x-qsfp56'
|
||||
TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd'
|
||||
TYPE_400GE_CFP2 = '400gbase-x-cfp2'
|
||||
TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
|
||||
TYPE_400GE_OSFP = '400gbase-x-osfp'
|
||||
TYPE_400GE_CDFP = '400gbase-x-cdfp'
|
||||
@ -958,6 +977,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100GE_CFP, 'CFP (100GE)'),
|
||||
(TYPE_100GE_CFP2, 'CFP2 (100GE)'),
|
||||
(TYPE_200GE_CFP2, 'CFP2 (200GE)'),
|
||||
(TYPE_400GE_CFP2, 'CFP2 (400GE)'),
|
||||
(TYPE_100GE_CFP4, 'CFP4 (100GE)'),
|
||||
(TYPE_100GE_CXP, 'CXP (100GE)'),
|
||||
(TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'),
|
||||
|
@ -1042,6 +1042,9 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
|
||||
queryset=VirtualDeviceContext.objects.all(),
|
||||
required=False,
|
||||
label='Virtual Device Contexts',
|
||||
initial_params={
|
||||
'interfaces': '$parent',
|
||||
},
|
||||
query_params={
|
||||
'device_id': '$device',
|
||||
}
|
||||
|
@ -53,23 +53,23 @@ class LinkPeerType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == CircuitTermination:
|
||||
if type(instance) is CircuitTermination:
|
||||
return CircuitTerminationType
|
||||
if type(instance) == ConsolePortType:
|
||||
if type(instance) is ConsolePortType:
|
||||
return ConsolePortType
|
||||
if type(instance) == ConsoleServerPort:
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) == FrontPort:
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == PowerFeed:
|
||||
if type(instance) is PowerFeed:
|
||||
return PowerFeedType
|
||||
if type(instance) == PowerOutlet:
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) == PowerPort:
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) == RearPort:
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
|
||||
@ -89,23 +89,23 @@ class CableTerminationTerminationType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == CircuitTermination:
|
||||
if type(instance) is CircuitTermination:
|
||||
return CircuitTerminationType
|
||||
if type(instance) == ConsolePortType:
|
||||
if type(instance) is ConsolePortType:
|
||||
return ConsolePortType
|
||||
if type(instance) == ConsoleServerPort:
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) == FrontPort:
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == PowerFeed:
|
||||
if type(instance) is PowerFeed:
|
||||
return PowerFeedType
|
||||
if type(instance) == PowerOutlet:
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) == PowerPort:
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) == RearPort:
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
||||
|
||||
@ -123,19 +123,19 @@ class InventoryItemTemplateComponentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == ConsolePortTemplate:
|
||||
if type(instance) is ConsolePortTemplate:
|
||||
return ConsolePortTemplateType
|
||||
if type(instance) == ConsoleServerPortTemplate:
|
||||
if type(instance) is ConsoleServerPortTemplate:
|
||||
return ConsoleServerPortTemplateType
|
||||
if type(instance) == FrontPortTemplate:
|
||||
if type(instance) is FrontPortTemplate:
|
||||
return FrontPortTemplateType
|
||||
if type(instance) == InterfaceTemplate:
|
||||
if type(instance) is InterfaceTemplate:
|
||||
return InterfaceTemplateType
|
||||
if type(instance) == PowerOutletTemplate:
|
||||
if type(instance) is PowerOutletTemplate:
|
||||
return PowerOutletTemplateType
|
||||
if type(instance) == PowerPortTemplate:
|
||||
if type(instance) is PowerPortTemplate:
|
||||
return PowerPortTemplateType
|
||||
if type(instance) == RearPortTemplate:
|
||||
if type(instance) is RearPortTemplate:
|
||||
return RearPortTemplateType
|
||||
|
||||
|
||||
@ -153,17 +153,17 @@ class InventoryItemComponentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == ConsolePort:
|
||||
if type(instance) is ConsolePort:
|
||||
return ConsolePortType
|
||||
if type(instance) == ConsoleServerPort:
|
||||
if type(instance) is ConsoleServerPort:
|
||||
return ConsoleServerPortType
|
||||
if type(instance) == FrontPort:
|
||||
if type(instance) is FrontPort:
|
||||
return FrontPortType
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == PowerOutlet:
|
||||
if type(instance) is PowerOutlet:
|
||||
return PowerOutletType
|
||||
if type(instance) == PowerPort:
|
||||
if type(instance) is PowerPort:
|
||||
return PowerPortType
|
||||
if type(instance) == RearPort:
|
||||
if type(instance) is RearPort:
|
||||
return RearPortType
|
||||
|
@ -232,7 +232,7 @@ class DeviceType(PrimaryModel, WeightMixin):
|
||||
super().clean()
|
||||
|
||||
# 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({
|
||||
'u_height': "U height must be in increments of 0.5 rack units."
|
||||
})
|
||||
|
@ -545,6 +545,11 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
}
|
||||
)
|
||||
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(
|
||||
linkify=True
|
||||
)
|
||||
@ -568,7 +573,7 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
||||
model = models.Interface
|
||||
fields = (
|
||||
'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',
|
||||
'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn',
|
||||
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
|
||||
|
@ -681,13 +681,6 @@ class RackView(generic.ObjectView):
|
||||
(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)
|
||||
|
||||
if instance.location:
|
||||
@ -704,7 +697,6 @@ class RackView(generic.ObjectView):
|
||||
|
||||
return {
|
||||
'related_models': related_models,
|
||||
'nonracked_devices': nonracked_devices,
|
||||
'next_rack': next_rack,
|
||||
'prev_rack': prev_rack,
|
||||
'svg_extra': svg_extra,
|
||||
@ -731,6 +723,26 @@ class RackRackReservationsView(generic.ObjectChildrenView):
|
||||
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')
|
||||
class RackEditView(generic.ObjectEditView):
|
||||
queryset = Rack.objects.all()
|
||||
|
@ -2,7 +2,6 @@ import collections
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import import_string
|
||||
from packaging import version
|
||||
@ -146,23 +145,3 @@ class PluginConfig(AppConfig):
|
||||
for setting, value in cls.default_settings.items():
|
||||
if setting not in user_config:
|
||||
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.")
|
||||
|
37
netbox/extras/plugins/utils.py
Normal file
37
netbox/extras/plugins/utils.py
Normal 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.")
|
@ -214,20 +214,18 @@ class Report(object):
|
||||
self.active_test = method_name
|
||||
test_method = getattr(self, method_name)
|
||||
test_method()
|
||||
job.data = self._results
|
||||
if self.failed:
|
||||
self.logger.warning("Report failed")
|
||||
job.status = JobStatusChoices.STATUS_FAILED
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
else:
|
||||
self.logger.info("Report completed successfully")
|
||||
job.status = JobStatusChoices.STATUS_COMPLETED
|
||||
job.terminate()
|
||||
except Exception as e:
|
||||
stacktrace = traceback.format_exc()
|
||||
self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
|
||||
logger.error(f"Exception raised during report execution: {e}")
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED)
|
||||
finally:
|
||||
job.data = self._results
|
||||
job.terminate()
|
||||
|
||||
# Perform any post-run tasks
|
||||
self.post_run()
|
||||
|
@ -5,8 +5,9 @@ from django.core.exceptions import ImproperlyConfigured
|
||||
from django.test import Client, TestCase, override_settings
|
||||
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.plugins.utils import get_plugin_config
|
||||
from netbox.graphql.schema import Query
|
||||
from netbox.registry import registry
|
||||
|
||||
|
@ -31,8 +31,8 @@ class WebhookTest(APITestCase):
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
DUMMY_URL = "http://localhost/"
|
||||
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
|
||||
DUMMY_URL = 'http://localhost:9000/'
|
||||
DUMMY_SECRET = 'LOOKATMEIMASECRETSTRING'
|
||||
|
||||
webhooks = Webhook.objects.bulk_create((
|
||||
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',
|
||||
type_create=True,
|
||||
type_update=True,
|
||||
payload_url='http://localhost/',
|
||||
payload_url='http://localhost:9000/',
|
||||
conditions={
|
||||
'and': [
|
||||
{
|
||||
|
@ -591,6 +591,10 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
method='_assigned_to_interface',
|
||||
label=_('Is assigned to an interface'),
|
||||
)
|
||||
assigned = django_filters.BooleanFilter(
|
||||
method='_assigned',
|
||||
label=_('Is assigned'),
|
||||
)
|
||||
status = django_filters.MultipleChoiceFilter(
|
||||
choices=IPAddressStatusChoices,
|
||||
null_value=None
|
||||
@ -706,6 +710,18 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
|
||||
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):
|
||||
protocol = django_filters.MultipleChoiceFilter(
|
||||
|
@ -1,7 +1,6 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Q
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from dcim.models import Device, Interface, Site
|
||||
@ -10,7 +9,9 @@ from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
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
|
||||
|
||||
__all__ = (
|
||||
@ -41,10 +42,25 @@ class VRFImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
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:
|
||||
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):
|
||||
|
@ -24,11 +24,11 @@ class IPAddressAssignmentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == FHRPGroup:
|
||||
if type(instance) is FHRPGroup:
|
||||
return FHRPGroupType
|
||||
if type(instance) == VMInterface:
|
||||
if type(instance) is VMInterface:
|
||||
return VMInterfaceType
|
||||
|
||||
|
||||
@ -42,11 +42,11 @@ class L2VPNAssignmentType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == VLAN:
|
||||
if type(instance) is VLAN:
|
||||
return VLANType
|
||||
if type(instance) == VMInterface:
|
||||
if type(instance) is VMInterface:
|
||||
return VMInterfaceType
|
||||
|
||||
|
||||
@ -59,9 +59,9 @@ class FHRPGroupInterfaceType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Interface:
|
||||
if type(instance) is Interface:
|
||||
return InterfaceType
|
||||
if type(instance) == VMInterface:
|
||||
if type(instance) is VMInterface:
|
||||
return VMInterfaceType
|
||||
|
||||
|
||||
@ -79,17 +79,17 @@ class VLANGroupScopeType(graphene.Union):
|
||||
|
||||
@classmethod
|
||||
def resolve_type(cls, instance, info):
|
||||
if type(instance) == Cluster:
|
||||
if type(instance) is Cluster:
|
||||
return ClusterType
|
||||
if type(instance) == ClusterGroup:
|
||||
if type(instance) is ClusterGroup:
|
||||
return ClusterGroupType
|
||||
if type(instance) == Location:
|
||||
if type(instance) is Location:
|
||||
return LocationType
|
||||
if type(instance) == Rack:
|
||||
if type(instance) is Rack:
|
||||
return RackType
|
||||
if type(instance) == Region:
|
||||
if type(instance) is Region:
|
||||
return RegionType
|
||||
if type(instance) == Site:
|
||||
if type(instance) is Site:
|
||||
return SiteType
|
||||
if type(instance) == SiteGroup:
|
||||
if type(instance) is SiteGroup:
|
||||
return SiteGroupType
|
||||
|
@ -992,6 +992,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
|
||||
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):
|
||||
params = {'assigned_to_interface': 'true'}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||
|
@ -121,7 +121,7 @@ def add_available_vlans(vlans, vlan_group=None):
|
||||
})
|
||||
|
||||
vlans = list(vlans) + new_vlans
|
||||
vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
|
||||
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])
|
||||
|
||||
return vlans
|
||||
|
||||
|
@ -11,6 +11,7 @@ from rest_framework.reverse import reverse
|
||||
from rest_framework.views import APIView
|
||||
from rq.worker import Worker
|
||||
|
||||
from extras.plugins.utils import get_installed_plugins
|
||||
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
|
||||
|
||||
|
||||
@ -61,19 +62,11 @@ class StatusView(APIView):
|
||||
installed_apps[app_config.name] = version
|
||||
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({
|
||||
'django-version': DJANGO_VERSION,
|
||||
'installed-apps': installed_apps,
|
||||
'netbox-version': settings.VERSION,
|
||||
'plugins': plugins,
|
||||
'plugins': get_installed_plugins(),
|
||||
'python-version': platform.python_version(),
|
||||
'rq-workers-running': Worker.count(get_connection('default')),
|
||||
})
|
||||
|
@ -442,6 +442,19 @@ class SyncedDataMixin(models.Model):
|
||||
|
||||
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):
|
||||
"""
|
||||
Determine the designated DataFile object identified by its parent DataSource and its path. Returns None if
|
||||
|
@ -46,7 +46,7 @@ ORGANIZATION_MENU = Menu(
|
||||
get_model_item('tenancy', 'contact', _('Contacts')),
|
||||
get_model_item('tenancy', 'contactgroup', _('Contact Groups')),
|
||||
get_model_item('tenancy', 'contactrole', _('Contact Roles')),
|
||||
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=[]),
|
||||
get_model_item('tenancy', 'contactassignment', _('Contact Assignments'), actions=['import']),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.5.7-dev'
|
||||
VERSION = '3.5.8-dev'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -461,8 +461,6 @@ LOGIN_REDIRECT_URL = f'/{BASE_PATH}'
|
||||
|
||||
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
|
||||
# by specifying the model individually in the EXEMPT_VIEW_PERMISSIONS configuration parameter.
|
||||
EXEMPT_EXCLUDE_MODELS = (
|
||||
|
@ -54,7 +54,7 @@ class BaseTable(tables.Table):
|
||||
# 3. Meta.fields
|
||||
selected_columns = None
|
||||
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:
|
||||
selected_columns = getattr(self.Meta, 'default_columns', self.Meta.fields)
|
||||
|
||||
@ -113,6 +113,10 @@ class BaseTable(tables.Table):
|
||||
columns.append((name, column.verbose_name))
|
||||
return columns
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.__class__.__name__
|
||||
|
||||
@property
|
||||
def available_columns(self):
|
||||
return self._get_columns(visible=False)
|
||||
@ -138,17 +142,16 @@ class BaseTable(tables.Table):
|
||||
"""
|
||||
# Save ordering preference
|
||||
if request.user.is_authenticated:
|
||||
table_name = self.__class__.__name__
|
||||
if self.prefixed_order_by_field in request.GET:
|
||||
if request.GET[self.prefixed_order_by_field]:
|
||||
# If an ordering has been specified as a query parameter, save it as the
|
||||
# user's preferred ordering for this table.
|
||||
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:
|
||||
# If the ordering has been set to none (empty), clear any existing preference.
|
||||
request.user.config.clear(f'tables.{table_name}.ordering', commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{table_name}.ordering'):
|
||||
request.user.config.clear(f'tables.{self.name}.ordering', commit=True)
|
||||
elif ordering := request.user.config.get(f'tables.{self.name}.ordering'):
|
||||
# If no ordering has been specified, set the preferred ordering (if any).
|
||||
self.order_by = ordering
|
||||
|
||||
|
@ -11,6 +11,8 @@ from django.views.defaults import ERROR_500_TEMPLATE_NAME, page_not_found
|
||||
from django.views.generic import View
|
||||
from sentry_sdk import capture_message
|
||||
|
||||
from extras.plugins.utils import get_installed_plugins
|
||||
|
||||
__all__ = (
|
||||
'handler_404',
|
||||
'handler_500',
|
||||
@ -53,4 +55,5 @@ def handler_500(request, template_name=ERROR_500_TEMPLATE_NAME):
|
||||
'exception': str(type_),
|
||||
'netbox_version': settings.VERSION,
|
||||
'python_version': platform.python_version(),
|
||||
'plugins': get_installed_plugins(),
|
||||
}))
|
||||
|
@ -30,7 +30,10 @@
|
||||
{{ error }}
|
||||
|
||||
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>
|
||||
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>
|
||||
|
@ -190,7 +190,6 @@
|
||||
</div>
|
||||
</div>
|
||||
{% include 'inc/panels/related_objects.html' %}
|
||||
{% include 'dcim/inc/nonracked_devices.html' %}
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
</div>
|
||||
|
51
netbox/templates/dcim/rack/non_racked_devices.html
Normal file
51
netbox/templates/dcim/rack/non_racked_devices.html
Normal 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 %}
|
@ -1,9 +1,11 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.utils.translation import gettext as _
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from tenancy.models import *
|
||||
from utilities.forms.fields import CSVModelChoiceField, SlugField
|
||||
from utilities.forms.fields import CSVContentTypeField, CSVModelChoiceField, SlugField
|
||||
|
||||
__all__ = (
|
||||
'ContactAssignmentImportForm',
|
||||
'ContactImportForm',
|
||||
'ContactGroupImportForm',
|
||||
'ContactRoleImportForm',
|
||||
@ -81,3 +83,27 @@ class ContactImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Contact
|
||||
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')
|
||||
|
@ -49,6 +49,7 @@ urlpatterns = [
|
||||
# Contact assignments
|
||||
path('contact-assignments/', views.ContactAssignmentListView.as_view(), name='contactassignment_list'),
|
||||
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/delete/', views.ContactAssignmentBulkDeleteView.as_view(), name='contactassignment_bulk_delete'),
|
||||
path('contact-assignments/<int:pk>/', include(get_model_urls('tenancy', 'contactassignment'))),
|
||||
|
@ -420,6 +420,11 @@ class ContactAssignmentBulkEditView(generic.BulkEditView):
|
||||
form = forms.ContactAssignmentBulkEditForm
|
||||
|
||||
|
||||
class ContactAssignmentBulkImportView(generic.BulkImportView):
|
||||
queryset = ContactAssignment.objects.all()
|
||||
model_form = forms.ContactAssignmentImportForm
|
||||
|
||||
|
||||
class ContactAssignmentBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ContactAssignment.objects.all()
|
||||
filterset = filtersets.ContactAssignmentFilterSet
|
||||
|
@ -123,9 +123,9 @@ class BulkImportForm(BootstrapMixin, SyncedDataMixin, forms.Form):
|
||||
records = []
|
||||
try:
|
||||
for data in yaml.load_all(data, Loader=yaml.SafeLoader):
|
||||
if type(data) == list:
|
||||
if type(data) is list:
|
||||
records.extend(data)
|
||||
elif type(data) == dict:
|
||||
elif type(data) is dict:
|
||||
records.append(data)
|
||||
else:
|
||||
raise forms.ValidationError({
|
||||
|
@ -114,7 +114,7 @@ def annotated_date(date_value):
|
||||
if not date_value:
|
||||
return ''
|
||||
|
||||
if type(date_value) == datetime.date:
|
||||
if type(date_value) is datetime.date:
|
||||
long_ts = date(date_value, 'DATE_FORMAT')
|
||||
short_ts = date(date_value, 'SHORT_DATE_FORMAT')
|
||||
else:
|
||||
|
@ -1,5 +1,5 @@
|
||||
bleach==6.0.0
|
||||
boto3==1.28.1
|
||||
boto3==1.28.14
|
||||
Django==4.1.10
|
||||
django-cors-headers==4.2.0
|
||||
django-debug-toolbar==4.1.0
|
||||
@ -15,21 +15,21 @@ django-tables2==2.6.0
|
||||
django-taggit==4.0.0
|
||||
django-timezone-field==5.1
|
||||
djangorestframework==3.14.0
|
||||
drf-spectacular==0.26.3
|
||||
drf-spectacular==0.26.4
|
||||
drf-spectacular-sidecar==2023.7.1
|
||||
dulwich==0.21.5
|
||||
feedparser==6.0.10
|
||||
graphene-django==3.0.0
|
||||
gunicorn==20.1.0
|
||||
gunicorn==21.2.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.1.18
|
||||
mkdocs-material==9.1.21
|
||||
mkdocstrings[python-legacy]==0.22.0
|
||||
netaddr==0.8.0
|
||||
Pillow==10.0.0
|
||||
psycopg2-binary==2.9.6
|
||||
PyYAML==6.0
|
||||
sentry-sdk==1.28.0
|
||||
PyYAML==6.0.1
|
||||
sentry-sdk==1.28.1
|
||||
social-auth-app-django==5.2.0
|
||||
social-auth-core[openidconnect]==4.4.2
|
||||
svgwrite==1.4.3
|
||||
|
Loading…
Reference in New Issue
Block a user