mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 20:12:00 -06:00
commit
58d9057ccd
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.1.4
|
||||
placeholder: v4.1.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
@ -36,9 +36,8 @@ body:
|
||||
options:
|
||||
- I volunteer to perform this work (if approved)
|
||||
- I'm a NetBox Labs customer
|
||||
- This is a very minor change
|
||||
- N/A
|
||||
default: 3
|
||||
default: 2
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
|
5
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
5
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -31,16 +31,15 @@ body:
|
||||
options:
|
||||
- I volunteer to perform this work (if approved)
|
||||
- I'm a NetBox Labs customer
|
||||
- This is preventing me from using NetBox
|
||||
- N/A
|
||||
default: 3
|
||||
default: 2
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.1.4
|
||||
placeholder: v4.1.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
|
||||
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo_light.svg" width="400" alt="NetBox logo" />
|
||||
<p><strong>The cornerstone of every automated network</strong></p>
|
||||
<a href="https://github.com/netbox-community/netbox/releases"><img src="https://img.shields.io/github/v/release/netbox-community/netbox" alt="Latest release" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/blob/master/LICENSE.txt"><img src="https://img.shields.io/badge/license-Apache_2.0-blue.svg" alt="License" /></a>
|
||||
|
@ -42,7 +42,7 @@ django-rich
|
||||
|
||||
# Django integration for RQ (Reqis queuing)
|
||||
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
|
||||
django-rq
|
||||
django-rq<3.0
|
||||
|
||||
# Abstraction models for rendering and paginating HTML tables
|
||||
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
|
||||
@ -116,6 +116,10 @@ PyYAML
|
||||
# https://github.com/psf/requests/blob/main/HISTORY.md
|
||||
requests
|
||||
|
||||
# rq
|
||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||
rq<2.0
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
52
docs/administration/authentication/google.md
Normal file
52
docs/administration/authentication/google.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Google
|
||||
|
||||
This guide explains how to configure single sign-on (SSO) support for NetBox using [Google OAuth2](https://developers.google.com/identity/protocols/oauth2/web-server) as an authentication backend.
|
||||
|
||||
## Google OAuth2 Configuration
|
||||
|
||||
1. Log into [console.cloud.google.com](https://console.cloud.google.com/).
|
||||
2. Create new project for NetBox.
|
||||
3. Under "APIs and Services" click "OAuth consent screen" and enter the required information.
|
||||
4. Under "Credentials," click "Create Credentials" and select "OAuth 2.0 Client ID." Select type "Web application."
|
||||
- "Authorized JavaScript origins" should follow the format `http[s]://<netbox>[:<port>]`
|
||||
- "Authorized redirect URIs" should follow the format `http[s]://<netbox>[:<port>]/oauth/complete/google-oauth2/`
|
||||
5. Copy the "Client ID" and "Client Secret" values somewhere convenient.
|
||||
|
||||
!!! note
|
||||
Google requires the NetBox hostname to use a public top-level-domain (e.g. `.com`, `.net`). The use of IP addresses is not permitted (except `127.0.0.1`).
|
||||
|
||||
For more information, consult [Google's documentation](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites).
|
||||
|
||||
## NetBox Configuration
|
||||
|
||||
### 1. Enter configuration parameters
|
||||
|
||||
Enter the following configuration parameters in `configuration.py`, substituting your own values:
|
||||
|
||||
```python
|
||||
REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2'
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '{CLIENT_ID}'
|
||||
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '{CLIENT_SECRET}'
|
||||
```
|
||||
|
||||
### 2. Restart NetBox
|
||||
|
||||
Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below:
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Google. Click that link.
|
||||
|
||||

|
||||
|
||||
You should be redirected to Google's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account.
|
||||
|
||||

|
||||
|
||||
If successful, you will be redirected back to the NetBox UI, and will be logged in as the Google user. You can verify this by navigating to your profile (using the button at top right).
|
||||
|
||||
This user account has been replicated locally to NetBox, and can now be assigned groups and permissions.
|
@ -16,7 +16,7 @@ Under the Azure Active Directory dashboard, navigate to **Add > App registration
|
||||
|
||||
Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected.
|
||||
|
||||
Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/entraid-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
|
||||
Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes).
|
||||
|
||||

|
||||
|
||||
|
@ -72,6 +72,9 @@ script_order = (MyCustomScript, AnotherCustomScript)
|
||||
|
||||
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
|
||||
|
||||
!!! warning
|
||||
These are also defined and used as properties on the base custom script class, so don't use the same names as variables or override them in your custom script.
|
||||
|
||||
### `name`
|
||||
|
||||
This is the human-friendly names of your script. If omitted, the class name will be used.
|
||||
|
@ -76,4 +76,4 @@ When adding a new dependency, a short description of the package and the URL of
|
||||
|
||||
* When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. but never "Netbox" or any other deviation.
|
||||
|
||||
* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size.
|
||||
* There are SVG forms of the NetBox logo for both [light mode](../netbox_logo_light.svg) and [dark mode](../netbox_logo_dark.svg) available. It is preferred to use the SVG logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the desired size.
|
||||
|
@ -5,6 +5,10 @@ img {
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.md-content img {
|
||||
background-color: rgba(255, 255, 255, 0.64);
|
||||
}
|
||||
|
||||
/* Tables */
|
||||
table {
|
||||
margin-bottom: 24px;
|
||||
|
@ -1,4 +1,5 @@
|
||||
{style="height: 100px; margin-bottom: 3em"}
|
||||
{style="height: 100px; margin-bottom: 3em; background: none;"}
|
||||
{style="height: 100px; margin-bottom: 3em; background: none;"}
|
||||
|
||||
# The Premier Network Source of Truth
|
||||
|
||||
|
BIN
docs/media/authentication/google_login_portal.png
Normal file
BIN
docs/media/authentication/google_login_portal.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 28 KiB |
BIN
docs/media/authentication/netbox_google_login.png
Normal file
BIN
docs/media/authentication/netbox_google_login.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 20 KiB |
24
docs/netbox_logo_dark.svg
Normal file
24
docs/netbox_logo_dark.svg
Normal file
@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1299.6 366">
|
||||
<defs>
|
||||
<style>
|
||||
.cls-1 {
|
||||
fill: #00f2d4;
|
||||
}
|
||||
|
||||
.cls-1, .cls-2 {
|
||||
stroke-width: 0px;
|
||||
}
|
||||
|
||||
.cls-2 {
|
||||
fill: #fff;
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="Layer_1-2" data-name="Layer 1">
|
||||
<g>
|
||||
<path class="cls-2" d="M337.27,228.59c-12.35,0-22.88,7.8-26.94,18.74h-174.71c-2.9-7.83-9.12-14.04-16.95-16.95V55.67c10.94-4.06,18.74-14.59,18.74-26.94,0-15.87-12.86-28.73-28.73-28.73s-28.73,12.86-28.73,28.73c0,12.35,7.8,22.88,18.74,26.94v174.71c-10.94,4.06-18.74,14.59-18.74,26.94,0,4.28.94,8.33,2.62,11.98l-41.85,41.85c-3.65-1.68-7.7-2.62-11.98-2.62-15.87,0-28.73,12.86-28.73,28.73s12.86,28.73,28.73,28.73,28.73-12.86,28.73-28.73c0-4.28-.94-8.33-2.62-11.98l41.85-41.85c3.65,1.68,7.7,2.62,11.98,2.62,12.35,0,22.88-7.8,26.94-18.74h174.71c4.06,10.94,14.59,18.74,26.94,18.74,15.87,0,28.73-12.86,28.73-28.73s-12.86-28.73-28.73-28.73Z"/>
|
||||
<path class="cls-1" d="M366,28.73c0,15.87-12.86,28.73-28.73,28.73-4.28,0-8.33-.94-11.98-2.62l-41.85,41.85c1.68,3.65,2.62,7.7,2.62,11.98,0,12.35-7.8,22.88-18.74,26.94v174.71c10.94,4.06,18.74,14.59,18.74,26.94,0,15.87-12.86,28.73-28.73,28.73s-28.73-12.86-28.73-28.73c0-12.35,7.8-22.88,18.74-26.94v-174.71c-7.83-2.9-14.04-9.12-16.95-16.95H55.67c-4.06,10.94-14.59,18.74-26.94,18.74-15.87,0-28.73-12.86-28.73-28.73s12.86-28.73,28.73-28.73c12.35,0,22.88,7.8,26.94,18.74h174.71c4.06-10.94,14.59-18.74,26.94-18.74,4.28,0,8.33.94,11.98,2.62l41.85-41.85c-1.68-3.65-2.62-7.7-2.62-11.98,0-15.87,12.86-28.73,28.73-28.73s28.73,12.86,28.73,28.73ZM579.76,136.45c-4.63-4.38-10.18-7.68-16.24-9.66-6.09-2.07-12.48-3.11-18.91-3.08-9.75-.17-19.37,2.17-27.95,6.78-2.68,1.56-5.23,3.35-7.61,5.34v-9.04h-34.53v134.64h34.53v-69.06c-.08-5.7.68-11.38,2.26-16.86,1.26-4.03,3.36-7.74,6.17-10.89,2.41-2.69,5.44-4.74,8.84-5.96,3.71-1.26,7.6-1.89,11.51-1.85,2.99,0,5.97.41,8.84,1.23,2.62.91,5,2.38,6.99,4.32,2.11,2.28,3.78,4.93,4.93,7.81,1.32,4.12,1.95,8.42,1.85,12.74v78.52h34.53v-85.1c.22-7.94-1.18-15.84-4.11-23.23-2.37-6.33-6.16-12.03-11.1-16.65ZM744.41,169.34c2.28,8.16,3.46,16.6,3.49,25.08v13.77h-98.46c.38,2.33,1.22,4.57,2.47,6.58,1.83,3.77,4.51,7.08,7.81,9.66,3.42,2.8,7.32,4.96,11.51,6.37,4.42,1.57,9.08,2.33,13.77,2.26,5.63.24,11.21-1.19,16.03-4.11,5.19-3.31,9.78-7.48,13.57-12.33l3.49-4.11,26.31,20.14-3.29,4.52c-14.18,18.09-34.12,27.34-59.2,27.34-9.78.09-19.49-1.72-28.57-5.34-8.34-3.34-15.84-8.46-21.99-15.01-6.02-6.49-10.7-14.1-13.77-22.4-3.18-8.83-4.78-18.16-4.73-27.54-.02-9.49,1.72-18.9,5.14-27.75,3.36-8.35,8.32-15.96,14.59-22.4,6.24-6.44,13.72-11.54,21.99-15.01,8.74-3.58,18.1-5.4,27.54-5.34,11.92,0,21.99,2.06,30.42,6.37,7.92,3.9,14.87,9.52,20.35,16.44,5.36,6.74,9.28,14.5,11.51,22.82ZM711.31,178.39c-.43-2.36-.98-4.69-1.64-6.99-1.14-3.45-3.04-6.61-5.55-9.25-2.45-2.78-5.56-4.9-9.04-6.17-8.68-3.42-18.36-3.27-26.93.41-3.87,1.69-7.37,4.13-10.28,7.19-2.81,2.83-5.05,6.18-6.58,9.87-.73,1.58-1.28,3.23-1.64,4.93h61.66ZM827.24,230.8c-2.56.57-5.18.84-7.81.82-2.41.12-4.82-.37-6.99-1.44-1.42-1.08-2.55-2.49-3.29-4.11-.93-2.36-1.42-4.87-1.44-7.4-.21-3.29-.41-6.58-.41-9.87v-50.57h33.71v-31.45h-33.71v-34.53h-34.53v34.53h-21.79v31.45h21.79v58.79c-.04,5.15.24,10.3.82,15.42.38,5.56,1.99,10.97,4.73,15.83,3.21,5.18,7.85,9.32,13.36,11.92,5.76,2.88,13.36,4.32,23.43,4.32,3.71-.04,7.42-.31,11.1-.82,4.47-.56,8.79-1.95,12.74-4.11l2.88-1.44v-34.33l-8.43,4.93c-1.93,1.02-4.01,1.72-6.17,2.06ZM997.03,166.46c3.16,8.91,4.76,18.3,4.73,27.75.04,9.32-1.56,18.57-4.73,27.34-3.07,8.3-7.75,15.92-13.77,22.4-6.1,6.56-13.53,11.74-21.79,15.21-8.94,3.62-18.51,5.44-28.16,5.34-9.17-.04-18.22-2.07-26.52-5.96-4.12-1.71-7.93-4.07-11.31-6.99v9.87h-34.53V53.41h34.53v83.04c3.23-2.59,6.75-4.8,10.48-6.58,8.54-4.07,17.88-6.18,27.34-6.17,9.65-.09,19.22,1.72,28.16,5.34,8.18,3.52,15.58,8.62,21.79,15.01,5.91,6.58,10.57,14.17,13.77,22.4ZM963.11,178.8c-1.41-4.39-3.8-8.39-6.99-11.72-3.07-3.26-6.78-5.85-10.89-7.61-9.47-3.57-19.92-3.57-29.39,0-4.12,1.76-7.83,4.35-10.89,7.61-3.12,3.37-5.5,7.37-6.99,11.72-1.71,4.96-2.55,10.17-2.47,15.42-.05,5.24.78,10.45,2.47,15.42,1.54,4.27,3.91,8.18,6.99,11.51,3.01,3.32,6.74,5.92,10.89,7.61,9.42,3.83,19.97,3.83,29.39,0,4.16-1.68,7.88-4.28,10.89-7.61,3.15-3.28,5.54-7.21,6.99-11.51,1.68-4.96,2.52-10.18,2.47-15.42.07-5.24-.77-10.46-2.47-15.42ZM1136.6,244.16c-28.24,27.15-72.89,27.15-101.13,0-13.17-13.29-20.56-31.24-20.55-49.95-.1-28.4,16.95-54.05,43.17-64.95,17.9-7.4,38.01-7.4,55.91,0,26.14,11,43.15,36.59,43.17,64.95,0,18.71-7.38,36.66-20.55,49.95ZM1118.51,178.8c-1.42-4.34-3.73-8.33-6.78-11.72-3.1-3.22-6.8-5.8-10.89-7.61-9.55-3.56-20.05-3.56-29.6,0-4.09,1.81-7.79,4.39-10.89,7.61-3.05,3.39-5.36,7.38-6.78,11.72-1.88,4.92-2.79,10.15-2.67,15.42-.08,5.26.82,10.49,2.67,15.42,1.47,4.25,3.77,8.17,6.78,11.51,3.05,3.28,6.77,5.87,10.89,7.61,9.49,3.84,20.11,3.84,29.6,0,4.13-1.74,7.84-4.33,10.89-7.61,3.01-3.34,5.32-7.26,6.78-11.51,1.75-4.95,2.66-10.16,2.67-15.42,0-5.25-.9-10.47-2.67-15.42ZM1291.58,126.79h-42.34l-26.52,39.47-26.93-39.47h-44.4l48.1,63.1-54.27,71.53h42.96l33.5-47.69,33.71,47.69h44.19l-54.27-71.53,46.25-63.1Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 5.0 KiB |
Before Width: | Height: | Size: 5.0 KiB After Width: | Height: | Size: 5.0 KiB |
@ -1,5 +1,29 @@
|
||||
# NetBox v4.1
|
||||
|
||||
## v4.1.5 (2024-10-28)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17789](https://github.com/netbox-community/netbox/issues/17789) - Provide a single "scope" field for bulk editing VLAN group scope assignments
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17358](https://github.com/netbox-community/netbox/issues/17358) - Fix validation of overlapping IP ranges
|
||||
* [#17374](https://github.com/netbox-community/netbox/issues/17374) - Fix styling of highlighted table rows in dark mode
|
||||
* [#17460](https://github.com/netbox-community/netbox/issues/17460) - Ensure bulk action buttons are consistent for device type components
|
||||
* [#17635](https://github.com/netbox-community/netbox/issues/17635) - Ensure AbortTransaction is caught when running a custom script with `commit=False`
|
||||
* [#17685](https://github.com/netbox-community/netbox/issues/17685) - Ensure background jobs are validated before being scheduled
|
||||
* [#17710](https://github.com/netbox-community/netbox/issues/17710) - Remove cached fields on CableTermination model from GraphQL API
|
||||
* [#17740](https://github.com/netbox-community/netbox/issues/17740) - Ensure support for image attachments with a `.webp` file extension
|
||||
* [#17749](https://github.com/netbox-community/netbox/issues/17749) - Restore missing `devicetypes` and `children` fields for several objects in GraphQL API
|
||||
* [#17754](https://github.com/netbox-community/netbox/issues/17754) - Remove paginator from version history table under plugin view
|
||||
* [#17759](https://github.com/netbox-community/netbox/issues/17759) - Retain `job_timeout` value when scheduling a recurring custom script
|
||||
* [#17774](https://github.com/netbox-community/netbox/issues/17774) - Fix SSO login support for Entra ID (formerly Azure AD)
|
||||
* [#17802](https://github.com/netbox-community/netbox/issues/17802) - Fix background color for bulk rename buttons in list views
|
||||
* [#17838](https://github.com/netbox-community/netbox/issues/17838) - Adjust `manage.py` to reference `python3` executable
|
||||
|
||||
---
|
||||
|
||||
## v4.1.4 (2024-10-15)
|
||||
|
||||
### Enhancements
|
||||
|
@ -156,6 +156,7 @@ nav:
|
||||
- Administration:
|
||||
- Authentication:
|
||||
- Overview: 'administration/authentication/overview.md'
|
||||
- Google: 'administration/authentication/google.md'
|
||||
- Microsoft Entra ID: 'administration/authentication/microsoft-entra-id.md'
|
||||
- Okta: 'administration/authentication/okta.md'
|
||||
- Permissions: 'administration/permissions.md'
|
||||
|
@ -130,7 +130,7 @@ class Job(models.Model):
|
||||
super().clean()
|
||||
|
||||
# Validate the assigned object type
|
||||
if self.object_type not in ObjectType.objects.with_feature('jobs'):
|
||||
if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'):
|
||||
raise ValidationError(
|
||||
_("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type)
|
||||
)
|
||||
@ -223,7 +223,7 @@ class Job(models.Model):
|
||||
rq_queue_name = get_queue_for_model(object_type.model if object_type else None)
|
||||
queue = django_rq.get_queue(rq_queue_name)
|
||||
status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING
|
||||
job = Job.objects.create(
|
||||
job = Job(
|
||||
object_type=object_type,
|
||||
object_id=object_id,
|
||||
name=name,
|
||||
@ -233,6 +233,8 @@ class Job(models.Model):
|
||||
user=user,
|
||||
job_id=uuid.uuid4()
|
||||
)
|
||||
job.full_clean()
|
||||
job.save()
|
||||
|
||||
# Run the job immediately, rather than enqueuing it as a background task. Note that this is a synchronous
|
||||
# (blocking) operation, and execution will pause until the job completes.
|
||||
|
@ -112,7 +112,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
|
||||
|
||||
@strawberry_django.type(
|
||||
models.CableTermination,
|
||||
exclude=('termination_type', 'termination_id'),
|
||||
exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'),
|
||||
filters=CableTerminationFilter
|
||||
)
|
||||
class CableTerminationType(NetBoxObjectType):
|
||||
@ -243,6 +243,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo
|
||||
consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]]
|
||||
poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]]
|
||||
frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]]
|
||||
devicebays: List[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]]
|
||||
modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]]
|
||||
services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]]
|
||||
inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]]
|
||||
|
@ -164,7 +164,7 @@ class Cable(PrimaryModel):
|
||||
if self.length is not None and not self.length_unit:
|
||||
raise ValidationError(_("Must specify a unit when setting a cable length"))
|
||||
|
||||
if self._state.adding and (not self.a_terminations or not self.b_terminations):
|
||||
if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations):
|
||||
raise ValidationError(_("Must define A and B terminations when creating a new cable."))
|
||||
|
||||
if self._terminations_modified:
|
||||
|
@ -49,7 +49,6 @@ class ScriptJob(JobRunner):
|
||||
script.log_info(message=_("Database changes have been reverted automatically."))
|
||||
if script.failed:
|
||||
logger.warning("Script failed")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
if type(e) is AbortScript:
|
||||
|
@ -33,7 +33,7 @@ def image_upload(instance, filename):
|
||||
|
||||
# Rename the file to the provided name, if any. Attempt to preserve the file extension.
|
||||
extension = filename.rsplit('.')[-1].lower()
|
||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']:
|
||||
if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']:
|
||||
filename = '.'.join([instance.name, extension])
|
||||
elif instance.name:
|
||||
filename = instance.name
|
||||
|
@ -105,6 +105,8 @@ IPAddressField.register_lookup(lookups.NetIn)
|
||||
IPAddressField.register_lookup(lookups.NetHostContained)
|
||||
IPAddressField.register_lookup(lookups.NetFamily)
|
||||
IPAddressField.register_lookup(lookups.NetMaskLength)
|
||||
IPAddressField.register_lookup(lookups.Host)
|
||||
IPAddressField.register_lookup(lookups.Inet)
|
||||
|
||||
|
||||
class ASNField(models.BigIntegerField):
|
||||
|
@ -1,22 +1,23 @@
|
||||
from django import forms
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.models import Location, Rack, Region, Site, SiteGroup
|
||||
from dcim.models import Region, Site, SiteGroup
|
||||
from ipam.choices import *
|
||||
from ipam.constants import *
|
||||
from ipam.models import *
|
||||
from ipam.models import ASN
|
||||
from netbox.forms import NetBoxModelBulkEditForm
|
||||
from tenancy.models import Tenant
|
||||
from utilities.forms import add_blank_choice
|
||||
from utilities.forms import add_blank_choice, get_field_value
|
||||
from utilities.forms.fields import (
|
||||
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
|
||||
NumericRangeArrayField,
|
||||
)
|
||||
from utilities.forms.rendering import FieldSet
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect
|
||||
from virtualization.models import Cluster, ClusterGroup
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect
|
||||
from utilities.templatetags.builtins.filters import bettertitle
|
||||
|
||||
__all__ = (
|
||||
'AggregateBulkEditForm',
|
||||
@ -429,62 +430,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
required=False
|
||||
)
|
||||
scope_type = ContentTypeChoiceField(
|
||||
label=_('Scope type'),
|
||||
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
|
||||
required=False
|
||||
)
|
||||
scope_id = forms.IntegerField(
|
||||
widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}),
|
||||
required=False,
|
||||
widget=forms.HiddenInput()
|
||||
label=_('Scope type')
|
||||
)
|
||||
region = DynamicModelChoiceField(
|
||||
label=_('Region'),
|
||||
queryset=Region.objects.all(),
|
||||
required=False
|
||||
)
|
||||
sitegroup = DynamicModelChoiceField(
|
||||
queryset=SiteGroup.objects.all(),
|
||||
scope = DynamicModelChoiceField(
|
||||
label=_('Scope'),
|
||||
queryset=Site.objects.none(), # Initial queryset
|
||||
required=False,
|
||||
label=_('Site group')
|
||||
)
|
||||
site = DynamicModelChoiceField(
|
||||
label=_('Site'),
|
||||
queryset=Site.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'region_id': '$region',
|
||||
'group_id': '$sitegroup',
|
||||
}
|
||||
)
|
||||
location = DynamicModelChoiceField(
|
||||
label=_('Location'),
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
}
|
||||
)
|
||||
rack = DynamicModelChoiceField(
|
||||
label=_('Rack'),
|
||||
queryset=Rack.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': '$site',
|
||||
'location_id': '$location',
|
||||
}
|
||||
)
|
||||
clustergroup = DynamicModelChoiceField(
|
||||
queryset=ClusterGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Cluster group')
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$clustergroup',
|
||||
}
|
||||
disabled=True,
|
||||
selector=True
|
||||
)
|
||||
vid_ranges = NumericRangeArrayField(
|
||||
label=_('VLAN ID ranges'),
|
||||
@ -494,24 +450,23 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
|
||||
model = VLANGroup
|
||||
fieldsets = (
|
||||
FieldSet('site', 'vid_ranges', 'description'),
|
||||
FieldSet(
|
||||
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
|
||||
),
|
||||
FieldSet('scope_type', 'scope', name=_('Scope')),
|
||||
)
|
||||
nullable_fields = ('description',)
|
||||
nullable_fields = ('description', 'scope')
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Assign scope based on scope_type
|
||||
if self.cleaned_data.get('scope_type'):
|
||||
scope_field = self.cleaned_data['scope_type'].model
|
||||
if scope_obj := self.cleaned_data.get(scope_field):
|
||||
self.cleaned_data['scope_id'] = scope_obj.pk
|
||||
self.changed_data.append('scope_id')
|
||||
else:
|
||||
self.cleaned_data.pop('scope_type')
|
||||
self.changed_data.remove('scope_type')
|
||||
if scope_type_id := get_field_value(self, 'scope_type'):
|
||||
try:
|
||||
scope_type = ContentType.objects.get(pk=scope_type_id)
|
||||
model = scope_type.model_class()
|
||||
self.fields['scope'].queryset = model.objects.all()
|
||||
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
|
||||
self.fields['scope'].disabled = False
|
||||
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
|
||||
class VLANBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
@ -580,15 +580,15 @@ class IPRange(ContactsMixin, PrimaryModel):
|
||||
})
|
||||
|
||||
# Check for overlapping ranges
|
||||
overlapping_range = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter(
|
||||
Q(start_address__gte=self.start_address, start_address__lte=self.end_address) | # Starts inside
|
||||
Q(end_address__gte=self.start_address, end_address__lte=self.end_address) | # Ends inside
|
||||
Q(start_address__lte=self.start_address, end_address__gte=self.end_address) # Starts & ends outside
|
||||
).first()
|
||||
if overlapping_range:
|
||||
overlapping_ranges = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter(
|
||||
Q(start_address__host__inet__gte=self.start_address.ip, start_address__host__inet__lte=self.end_address.ip) | # Starts inside
|
||||
Q(end_address__host__inet__gte=self.start_address.ip, end_address__host__inet__lte=self.end_address.ip) | # Ends inside
|
||||
Q(start_address__host__inet__lte=self.start_address.ip, end_address__host__inet__gte=self.end_address.ip) # Starts & ends outside
|
||||
)
|
||||
if overlapping_ranges.exists():
|
||||
raise ValidationError(
|
||||
_("Defined addresses overlap with range {overlapping_range} in VRF {vrf}").format(
|
||||
overlapping_range=overlapping_range,
|
||||
overlapping_range=overlapping_ranges.first(),
|
||||
vrf=self.vrf
|
||||
))
|
||||
|
||||
|
@ -36,6 +36,35 @@ class TestAggregate(TestCase):
|
||||
self.assertEqual(aggregate.get_utilization(), 100)
|
||||
|
||||
|
||||
class TestIPRange(TestCase):
|
||||
|
||||
def test_overlapping_range(self):
|
||||
iprange_192_168 = IPRange.objects.create(start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22'))
|
||||
iprange_192_168.clean()
|
||||
iprange_3_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.3.1/24'), end_address=IPNetwork('1.2.3.99/24'))
|
||||
iprange_3_1_99.clean()
|
||||
iprange_3_100_199 = IPRange.objects.create(start_address=IPNetwork('1.2.3.100/24'), end_address=IPNetwork('1.2.3.199/24'))
|
||||
iprange_3_100_199.clean()
|
||||
iprange_3_200_255 = IPRange.objects.create(start_address=IPNetwork('1.2.3.200/24'), end_address=IPNetwork('1.2.3.255/24'))
|
||||
iprange_3_200_255.clean()
|
||||
iprange_4_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.4.1/24'), end_address=IPNetwork('1.2.4.99/24'))
|
||||
iprange_4_1_99.clean()
|
||||
iprange_4_200 = IPRange.objects.create(start_address=IPNetwork('1.2.4.200/24'), end_address=IPNetwork('1.2.4.255/24'))
|
||||
iprange_4_200.clean()
|
||||
# Overlapping range entirely within existing
|
||||
with self.assertRaises(ValidationError):
|
||||
iprange_3_123_124 = IPRange.objects.create(start_address=IPNetwork('1.2.3.123/26'), end_address=IPNetwork('1.2.3.124/26'))
|
||||
iprange_3_123_124.clean()
|
||||
# Overlapping range starting within existing
|
||||
with self.assertRaises(ValidationError):
|
||||
iprange_4_98_101 = IPRange.objects.create(start_address=IPNetwork('1.2.4.98/24'), end_address=IPNetwork('1.2.4.101/24'))
|
||||
iprange_4_98_101.clean()
|
||||
# Overlapping range ending within existing
|
||||
with self.assertRaises(ValidationError):
|
||||
iprange_4_198_201 = IPRange.objects.create(start_address=IPNetwork('1.2.4.198/24'), end_address=IPNetwork('1.2.4.201/24'))
|
||||
iprange_4_198_201.clean()
|
||||
|
||||
|
||||
class TestPrefix(TestCase):
|
||||
|
||||
def test_get_duplicates(self):
|
||||
|
@ -1,4 +1,4 @@
|
||||
#!/usr/bin/env python
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
@ -20,10 +20,10 @@ AUTH_BACKEND_ATTRS = {
|
||||
'amazon': ('Amazon AWS', 'aws'),
|
||||
'apple': ('Apple', 'apple'),
|
||||
'auth0': ('Auth0', None),
|
||||
'entraid-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'entraid-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'entraid-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'entraid-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'azuread-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'),
|
||||
'bitbucket': ('BitBucket', 'bitbucket'),
|
||||
'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
|
||||
'digitalocean': ('DigitalOcean', 'digital-ocean'),
|
||||
|
@ -68,6 +68,8 @@ class JobRunner(ABC):
|
||||
finally:
|
||||
if job.interval:
|
||||
new_scheduled_time = (job.scheduled or job.started) + timedelta(minutes=job.interval)
|
||||
if job.object and getattr(job.object, "python_class", None):
|
||||
kwargs["job_timeout"] = job.object.python_class.job_timeout
|
||||
cls.enqueue(
|
||||
instance=job.object,
|
||||
user=job.user,
|
||||
|
@ -5,7 +5,7 @@ from django.utils import timezone
|
||||
from django_rq import get_queue
|
||||
|
||||
from ..jobs import *
|
||||
from core.models import Job
|
||||
from core.models import DataSource, Job
|
||||
from core.choices import JobStatusChoices
|
||||
|
||||
|
||||
@ -68,7 +68,7 @@ class EnqueueTest(JobRunnerTestCase):
|
||||
"""
|
||||
|
||||
def test_enqueue(self):
|
||||
instance = Job()
|
||||
instance = DataSource()
|
||||
for i in range(1, 3):
|
||||
job = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at())
|
||||
|
||||
@ -76,13 +76,13 @@ class EnqueueTest(JobRunnerTestCase):
|
||||
self.assertEqual(TestJobRunner.get_jobs(instance).count(), i)
|
||||
|
||||
def test_enqueue_once(self):
|
||||
job = TestJobRunner.enqueue_once(instance=Job(), schedule_at=self.get_schedule_at())
|
||||
job = TestJobRunner.enqueue_once(instance=DataSource(), schedule_at=self.get_schedule_at())
|
||||
|
||||
self.assertIsInstance(job, Job)
|
||||
self.assertEqual(job.name, TestJobRunner.__name__)
|
||||
|
||||
def test_enqueue_once_twice_same(self):
|
||||
instance = Job()
|
||||
instance = DataSource()
|
||||
schedule_at = self.get_schedule_at()
|
||||
job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at)
|
||||
job2 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at)
|
||||
@ -91,7 +91,7 @@ class EnqueueTest(JobRunnerTestCase):
|
||||
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
|
||||
|
||||
def test_enqueue_once_twice_different_schedule_at(self):
|
||||
instance = Job()
|
||||
instance = DataSource()
|
||||
job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at())
|
||||
job2 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2))
|
||||
|
||||
@ -100,7 +100,7 @@ class EnqueueTest(JobRunnerTestCase):
|
||||
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
|
||||
|
||||
def test_enqueue_once_twice_different_interval(self):
|
||||
instance = Job()
|
||||
instance = DataSource()
|
||||
schedule_at = self.get_schedule_at()
|
||||
job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at)
|
||||
job2 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at, interval=60)
|
||||
@ -112,7 +112,7 @@ class EnqueueTest(JobRunnerTestCase):
|
||||
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
|
||||
|
||||
def test_enqueue_once_with_enqueue(self):
|
||||
instance = Job()
|
||||
instance = DataSource()
|
||||
job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2))
|
||||
job2 = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at())
|
||||
|
||||
@ -120,7 +120,7 @@ class EnqueueTest(JobRunnerTestCase):
|
||||
self.assertEqual(TestJobRunner.get_jobs(instance).count(), 2)
|
||||
|
||||
def test_enqueue_once_after_enqueue(self):
|
||||
instance = Job()
|
||||
instance = DataSource()
|
||||
job1 = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at())
|
||||
job2 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2))
|
||||
|
||||
|
@ -3,7 +3,7 @@ import re
|
||||
from copy import deepcopy
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.fields import GenericRel
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
@ -576,7 +576,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
for name, model_field in model_fields.items():
|
||||
# Handle nullification
|
||||
if name in form.nullable_fields and name in nullified_fields:
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
if type(model_field) is GenericForeignKey:
|
||||
setattr(obj, name, None)
|
||||
else:
|
||||
setattr(obj, name, None if model_field.null else '')
|
||||
# Normal fields
|
||||
elif name in form.changed_data:
|
||||
setattr(obj, name, form.cleaned_data[name])
|
||||
@ -688,7 +691,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
else:
|
||||
form = self.form(initial=initial_data)
|
||||
form = self.form(request.POST, initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
# Retrieve objects being edited
|
||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
@ -30,7 +30,7 @@
|
||||
"gridstack": "10.3.1",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.79.5",
|
||||
"sass": "1.80.4",
|
||||
"tom-select": "2.3.1",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
@ -28,16 +28,19 @@
|
||||
|
||||
}
|
||||
|
||||
// Remove the bottom margin of <p> elements inside a table cell
|
||||
td > .rendered-markdown {
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
|
||||
// Remove the bottom margin of the last <p> elements in markdown
|
||||
.rendered-markdown {
|
||||
p:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// fix layout of rendered markdown inside a table cell
|
||||
td > .rendered-markdown {
|
||||
max-height: 200px;
|
||||
overflow-y: scroll;
|
||||
}
|
||||
|
||||
// Markdown preview
|
||||
.markdown-widget {
|
||||
.preview {
|
||||
|
@ -131,6 +131,11 @@ body[data-bs-theme=dark] {
|
||||
.toast {
|
||||
color: var(--#{$prefix}body-color);
|
||||
}
|
||||
.table-primary {
|
||||
--tblr-table-bg: rgba(var(--tblr-secondary-rgb), 0.48);
|
||||
--tblr-table-hover-bg: inherit;
|
||||
--tblr-table-hover-color: inherit;
|
||||
}
|
||||
}
|
||||
|
||||
// Do not apply padding to <code> elements inside a <pre>
|
||||
|
@ -2656,10 +2656,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.79.5:
|
||||
version "1.79.5"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.5.tgz#646c627601cd5f84c64f7b1485b9292a313efae4"
|
||||
integrity sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==
|
||||
sass@1.80.4:
|
||||
version "1.80.4"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.4.tgz#bc0418fd796cad2f1a1309d8b4d7fe44b7027de0"
|
||||
integrity sha512-rhMQ2tSF5CsuuspvC94nPM9rToiAFw2h3JTrLlgmNw1MH79v8Cr3DH6KF6o6r+8oofY3iYVPUf66KzC8yuVN1w==
|
||||
dependencies:
|
||||
"@parcel/watcher" "^2.4.1"
|
||||
chokidar "^4.0.0"
|
||||
|
@ -1,3 +1,3 @@
|
||||
version: "4.1.4"
|
||||
version: "4.1.5"
|
||||
edition: "Community"
|
||||
published: "2024-10-15"
|
||||
published: "2024-10-28"
|
||||
|
@ -2,6 +2,7 @@
|
||||
{% load helpers %}
|
||||
{% load form_helpers %}
|
||||
{% load i18n %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}{{ plugin.title_long }}{% endblock %}
|
||||
|
||||
@ -93,8 +94,8 @@
|
||||
<div class="col col-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Version History" %}</h2>
|
||||
<div class="htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
<div class="table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -10,7 +10,7 @@
|
||||
{% endif %}
|
||||
{% if 'bulk_rename' in actions %}
|
||||
{% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning">
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-float">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
|
||||
</button>
|
||||
{% endwith %}
|
||||
|
@ -78,7 +78,7 @@
|
||||
{% if 'bulk_edit' in actions %}
|
||||
<div class="btn-group" role="group">
|
||||
{% bulk_edit_button model query_params=request.GET %}
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning">
|
||||
<button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning btn-float">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
|
||||
</button>
|
||||
</div>
|
||||
|
@ -18,21 +18,8 @@
|
||||
<button type="submit" name="_rename"
|
||||
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
|
||||
class="btn btn-outline-warning">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock bulk_edit_controls %}
|
||||
|
||||
{% block bulk_extra_controls %}
|
||||
{{ block.super }}
|
||||
{% if request.user|can_add:child_model %}
|
||||
<div class="bulk-button-group">
|
||||
<a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock bulk_extra_controls %}
|
||||
|
||||
|
2
netbox/templates/dcim/inc/devicetype_breadcrumbs.html
Normal file
2
netbox/templates/dcim/inc/devicetype_breadcrumbs.html
Normal file
@ -0,0 +1,2 @@
|
||||
|
||||
<li class="breadcrumb-item"><a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.manufacturer.pk }}">{{ object.manufacturer }}</a></li>
|
38
netbox/templates/dcim/inc/moduletype_buttons.html
Normal file
38
netbox/templates/dcim/inc/moduletype_buttons.html
Normal file
@ -0,0 +1,38 @@
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
|
||||
{% if perms.dcim.change_devicetype %}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle"data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if perms.dcim.add_consoleporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_consoleports' pk=object.pk %}">{% trans "Console Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_consoleserverports' pk=object.pk %}">{% trans "Console Server Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_powerporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:powerporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_powerports' pk=object.pk %}">{% trans "Power Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlettemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlettemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_poweroutlets' pk=object.pk %}">{% trans "Power Outlets" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interfacetemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interfacetemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_interfaces' pk=object.pk %}">{% trans "Interfaces" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_frontporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_frontports' pk=object.pk %}">{% trans "Front Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rearporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_modulebaytemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:modulebaytemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
@ -1,9 +1,20 @@
|
||||
{% extends 'dcim/moduletype/base.html' %}
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
{% include 'dcim/inc/devicetype_breadcrumbs.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% include 'dcim/inc/moduletype_buttons.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-6">
|
||||
|
@ -1,48 +0,0 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
<li class="breadcrumb-item"><a href="{% url 'dcim:moduletype_list' %}?manufacturer_id={{ object.manufacturer.pk }}">{{ object.manufacturer }}</a></li>
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% if perms.dcim.change_devicetype %}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle"data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
{% if perms.dcim.add_consoleporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_consoleports' pk=object.pk %}">{% trans "Console Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_consoleserverporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:consoleserverporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_consoleserverports' pk=object.pk %}">{% trans "Console Server Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_powerporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:powerporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_powerports' pk=object.pk %}">{% trans "Power Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_poweroutlettemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:poweroutlettemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_poweroutlets' pk=object.pk %}">{% trans "Power Outlets" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_interfacetemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:interfacetemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_interfaces' pk=object.pk %}">{% trans "Interfaces" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_frontporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:frontporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_frontports' pk=object.pk %}">{% trans "Front Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_rearporttemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
|
||||
{% endif %}
|
||||
{% if perms.dcim.add_modulebaytemplate %}
|
||||
<li><a class="dropdown-item" href="{% url 'dcim:modulebaytemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
@ -1,44 +1,37 @@
|
||||
{% extends 'dcim/moduletype/base.html' %}
|
||||
{% extends 'generic/object_children.html' %}
|
||||
{% load render_table from django_tables2 %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block content %}
|
||||
{% if perms.dcim.change_moduletype %}
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<div class="card">
|
||||
<div class="htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
<div class="card-footer d-print-none">
|
||||
{% if table.rows %}
|
||||
<button type="submit" name="_edit" {% formaction %}="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-warning">
|
||||
<span class="mdi mdi-pencil-outline" aria-hidden="true"></span> {% trans "Rename" %}
|
||||
</button>
|
||||
<button type="submit" name="_edit" {% formaction %}="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-warning">
|
||||
<span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
|
||||
</button>
|
||||
<button type="submit" name="_delete" {% formaction %}="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-danger">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
|
||||
</button>
|
||||
{% endif %}
|
||||
<div class="float-end">
|
||||
<a href="{% url table.Meta.model|viewname:"add" %}?module_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
|
||||
{% trans "Add" %} {{ title }}
|
||||
</a>
|
||||
</div>
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{{ title }}</h2>
|
||||
<div class="htmx-container table-responsive" id="object_list">
|
||||
{% include 'htmx/table.html' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock content %}
|
||||
{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
{{ block.super }}
|
||||
{% include 'dcim/inc/devicetype_breadcrumbs.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% include 'dcim/inc/moduletype_buttons.html' %}
|
||||
{% endblock %}
|
||||
|
||||
{% block bulk_edit_controls %}
|
||||
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
|
||||
{% if 'bulk_edit' in actions and bulk_edit_view %}
|
||||
<button type="submit" name="_edit"
|
||||
{% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
|
||||
class="btn btn-warning">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
|
||||
{% if 'bulk_rename' in actions and bulk_rename_view %}
|
||||
<button type="submit" name="_rename"
|
||||
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
|
||||
class="btn btn-outline-warning">
|
||||
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endblock bulk_edit_controls %}
|
||||
|
||||
|
@ -42,71 +42,71 @@ Context:
|
||||
{# Edit form #}
|
||||
<div class="tab-pane show active" id="edit-form" role="tabpanel" aria-labelledby="edit-form-tab">
|
||||
<form action="" method="post" class="form form-horizontal mt-5">
|
||||
|
||||
{% csrf_token %}
|
||||
{% if request.POST.return_url %}
|
||||
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
|
||||
{% endif %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
{% if form.fieldsets %}
|
||||
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for fieldset in form.fieldsets %}
|
||||
{% render_fieldset form fieldset %}
|
||||
<div id="form_fields" hx-disinherit="hx-select hx-swap">
|
||||
{% csrf_token %}
|
||||
{% if request.POST.return_url %}
|
||||
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
|
||||
{% endif %}
|
||||
{% for field in form.hidden_fields %}
|
||||
{{ field }}
|
||||
{% endfor %}
|
||||
|
||||
{# Render tag add/remove fields #}
|
||||
{% if form.add_tags and form.remove_tags %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Tags" %}</h2>
|
||||
{% if form.fieldsets %}
|
||||
|
||||
{# Render grouped fields according to declared fieldsets #}
|
||||
{% for fieldset in form.fieldsets %}
|
||||
{% render_fieldset form fieldset %}
|
||||
{% endfor %}
|
||||
|
||||
{# Render tag add/remove fields #}
|
||||
{% if form.add_tags and form.remove_tags %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Tags" %}</h2>
|
||||
</div>
|
||||
{% render_field form.add_tags %}
|
||||
{% render_field form.remove_tags %}
|
||||
</div>
|
||||
{% render_field form.add_tags %}
|
||||
{% render_field form.remove_tags %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Render custom fields #}
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Render comments #}
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Comments" %}</h2>
|
||||
</div>
|
||||
{% render_field form.comments bulk_nullable=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{# Render all fields #}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name in form.nullable_fields %}
|
||||
{% render_field field bulk_nullable=True %}
|
||||
{% else %}
|
||||
{% render_field field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
{# Render custom fields #}
|
||||
{% if form.custom_fields %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Custom Fields" %}</h2>
|
||||
</div>
|
||||
{% render_custom_fields form %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-float-group-right">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
|
||||
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
|
||||
{# Render comments #}
|
||||
{% if form.comments %}
|
||||
<div class="field-group mb-5">
|
||||
<div class="row">
|
||||
<h2 class="col-9 offset-3">{% trans "Comments" %}</h2>
|
||||
</div>
|
||||
{% render_field form.comments bulk_nullable=True %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
|
||||
{# Render all fields #}
|
||||
{% for field in form.visible_fields %}
|
||||
{% if field.name in form.nullable_fields %}
|
||||
{% render_field field bulk_nullable=True %}
|
||||
{% else %}
|
||||
{% render_field field %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% endif %}
|
||||
|
||||
<div class="btn-float-group-right">
|
||||
<a href="{{ return_url }}" class="btn btn-outline-secondary btn-float">{% trans "Cancel" %}</a>
|
||||
<button type="submit" name="_apply" class="btn btn-primary">{% trans "Apply" %}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
|
||||
|
@ -66,6 +66,7 @@ class TenantGroupType(OrganizationalObjectType):
|
||||
parent: Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
|
||||
tenants: List[TenantType]
|
||||
children: List[Annotated["TenantGroupType", strawberry.lazy('tenancy.graphql.types')]]
|
||||
|
||||
|
||||
#
|
||||
@ -99,6 +100,7 @@ class ContactGroupType(OrganizationalObjectType):
|
||||
parent: Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')] | None
|
||||
|
||||
contacts: List[ContactType]
|
||||
children: List[Annotated["ContactGroupType", strawberry.lazy('tenancy.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -43,9 +43,12 @@ class HTMXSelect(forms.Select):
|
||||
"""
|
||||
Selection widget that will re-generate the HTML form upon the selection of a new option.
|
||||
"""
|
||||
def __init__(self, hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs):
|
||||
def __init__(self, method='get', hx_url='.', hx_target_id='form_fields', attrs=None, **kwargs):
|
||||
method = method.lower()
|
||||
if method not in ('delete', 'get', 'patch', 'post', 'put'):
|
||||
raise ValueError(f"Unsupported HTTP method: {method}")
|
||||
_attrs = {
|
||||
'hx-get': hx_url,
|
||||
f'hx-{method}': hx_url,
|
||||
'hx-include': f'#{hx_target_id}',
|
||||
'hx-target': f'#{hx_target_id}',
|
||||
}
|
||||
|
@ -23,6 +23,7 @@ class WirelessLANGroupType(OrganizationalObjectType):
|
||||
parent: Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')] | None
|
||||
|
||||
wireless_lans: List[Annotated["WirelessLANType", strawberry.lazy('wireless.graphql.types')]]
|
||||
children: List[Annotated["WirelessLANGroupType", strawberry.lazy('wireless.graphql.types')]]
|
||||
|
||||
|
||||
@strawberry_django.type(
|
||||
|
@ -2,13 +2,13 @@ Django==5.0.9
|
||||
django-cors-headers==4.5.0
|
||||
django-debug-toolbar==4.4.6
|
||||
django-filter==24.3
|
||||
django-htmx==1.19.0
|
||||
django-htmx==1.21.0
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.16.0
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.3.1
|
||||
django-redis==5.4.0
|
||||
django-rich==1.11.0
|
||||
django-rich==1.12.0
|
||||
django-rq==2.10.2
|
||||
django-taggit==6.1.0
|
||||
django-tables2==2.7.0
|
||||
@ -20,18 +20,19 @@ feedparser==6.0.11
|
||||
gunicorn==23.0.0
|
||||
Jinja2==3.1.4
|
||||
Markdown==3.7
|
||||
mkdocs-material==9.5.41
|
||||
mkdocs-material==9.5.42
|
||||
mkdocstrings[python-legacy]==0.26.2
|
||||
netaddr==1.3.0
|
||||
nh3==0.2.18
|
||||
Pillow==10.4.0
|
||||
Pillow==11.0.0
|
||||
psycopg[c,pool]==3.2.3
|
||||
PyYAML==6.0.2
|
||||
requests==2.32.3
|
||||
rq==1.16.2
|
||||
social-auth-app-django==5.4.2
|
||||
social-auth-core==4.5.4
|
||||
strawberry-graphql==0.246.2
|
||||
strawberry-graphql-django==0.48.0
|
||||
strawberry-graphql==0.247.0
|
||||
strawberry-graphql-django==0.49.1
|
||||
svgwrite==1.4.3
|
||||
tablib==3.7.0
|
||||
tzdata==2024.2
|
||||
|
Loading…
Reference in New Issue
Block a user