Merge v3.1.2

This commit is contained in:
jeremystretch 2021-12-20 16:28:11 -05:00
commit 71b4641e18
46 changed files with 564 additions and 210 deletions

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.1 placeholder: v3.1.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.1 placeholder: v3.1.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -6,9 +6,9 @@ Models within each app are stored in either `models.py` or within a submodule un
Each model should define, at a minimum: Each model should define, at a minimum:
* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
* A `__str__()` method returning a user-friendly string representation of the instance * A `__str__()` method returning a user-friendly string representation of the instance
* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`) * A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`)
* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
## 2. Define field choices ## 2. Define field choices
@ -16,9 +16,9 @@ If the model has one or more fields with static choices, define those choices in
## 3. Generate database migrations ## 3. Generate database migrations
Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations. Once your model definition is complete, generate database migrations by running `manage.py makemigrations -n $NAME --no-header`. Always specify a short unique name when generating migrations.
!!! info !!! info "Configuration Required"
Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations. Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations.
## 4. Add all standard views ## 4. Add all standard views
@ -41,9 +41,7 @@ Add the relevant URL path for each view created in the previous step to `urls.py
Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class. Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
Every model FilterSet should define a `q` filter to support general search queries. ## 7. Create the table class
## 7. Create the table
Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns. Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
@ -53,7 +51,7 @@ Create the HTML template for the object view. (The other views each typically em
## 9. Add the model to the navigation menu ## 9. Add the model to the navigation menu
For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`. Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
## 10. REST API components ## 10. REST API components
@ -64,7 +62,7 @@ Create the following for each model:
* API view in `api/views.py` * API view in `api/views.py`
* Endpoint route in `api/urls.py` * Endpoint route in `api/urls.py`
## 11. GraphQL API components (v3.0+) ## 11. GraphQL API components
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.

View File

@ -4,16 +4,16 @@ Below is a list of tasks to consider when adding a new field to a core model.
## 1. Generate and run database migrations ## 1. Generate and run database migrations
Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration. [Django migrations](https://docs.djangoproject.com/en/stable/topics/migrations/) are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
``` ```
./manage.py makemigrations <app> -n <name> ./manage.py makemigrations <app> -n <name>
./manage.py migrate ./manage.py migrate
``` ```
Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists. Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in a single migration. You can merge a newly generated migration with an existing one by combining their `operations` lists.
!!! note !!! warning "Do not alter existing migrations"
Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug). Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
## 2. Add validation logic to `clean()` ## 2. Add validation logic to `clean()`
@ -24,7 +24,6 @@ If the new field introduces additional validation requirements (beyond what's in
class Foo(models.Model): class Foo(models.Model):
def clean(self): def clean(self):
super().clean() super().clean()
# Custom validation goes here # Custom validation goes here
@ -40,9 +39,9 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model. Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
## 5. Add field to forms ## 5. Add fields to forms
Extend any forms to include the new field as appropriate. Common forms include: Extend any forms to include the new field(s) as appropriate. These are found under the `forms/` directory within each app. Common forms include:
* **Credit/edit** - Manipulating a single object * **Credit/edit** - Manipulating a single object
* **Bulk edit** - Performing a change on many objects at once * **Bulk edit** - Performing a change on many objects at once
@ -51,11 +50,11 @@ Extend any forms to include the new field as appropriate. Common forms include:
## 6. Extend object filter set ## 6. Extend object filter set
If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method. If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to query it in the FilterSet's `search()` method.
## 7. Add column to object table ## 7. Add column to object table
If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
## 8. Update the UI templates ## 8. Update the UI templates

View File

@ -35,6 +35,8 @@ The NetBox project utilizes three persistent git branches to track work:
Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch. Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0).
### Enable Pre-Commit Hooks ### Enable Pre-Commit Hooks
NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`: NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
@ -46,7 +48,7 @@ $ ln -s ../../scripts/git-hooks/pre-commit
### Create a Python Virtual Environment ### Create a Python Virtual Environment
A [virtual environment](https://docs.python.org/3/tutorial/venv.html) is like a container for a set of Python packages. They allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production. A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
Create a virtual environment using the `venv` Python module: Create a virtual environment using the `venv` Python module:
@ -57,8 +59,8 @@ $ python3 -m venv ~/.venv/netbox
This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`. This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
!!! info !!! info "Where to Create Your Virtual Environments"
Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created wherever you please. Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please.
Once created, activate the virtual environment: Once created, activate the virtual environment:
@ -94,7 +96,7 @@ Within the `netbox/netbox/` directory, copy `configuration.example.py` to `confi
### Start the Development Server ### Start the Development Server
Django provides a lightweight, auto-updating HTTP/WSGI server for development use. NetBox extends this slightly to automatically import models and other utilities. Run the NetBox development server with the `nbshell` management command: Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command:
```no-highlight ```no-highlight
$ python netbox/manage.py runserver $ python netbox/manage.py runserver
@ -109,9 +111,12 @@ Quit the server with CONTROL-C.
This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server. This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server.
!!! info "IDE Integration"
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
## Running Tests ## Running Tests
Throughout the course of development, it's a good idea to occasionally run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command: Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.
```no-highlight ```no-highlight
$ python netbox/manage.py test $ python netbox/manage.py test
@ -123,9 +128,15 @@ In cases where you haven't made any changes to the database (which is most of th
$ python netbox/manage.py test --keepdb $ python netbox/manage.py test --keepdb
``` ```
You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests:
```no-highlight
$ python netbox/manage.py test dcim.tests.test_views ipam.tests.test_views
```
## Submitting Pull Requests ## Submitting Pull Requests
Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to reference it. Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.
```no-highlight ```no-highlight
$ git commit -m "Closes #1234: Add IPv5 support" $ git commit -m "Closes #1234: Add IPv5 support"
@ -136,5 +147,5 @@ Once your fork has the new commit, submit a [pull request](https://github.com/ne
Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically. Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically.
!!! note !!! note "Remember to Open an Issue First"
Remember, pull requests are entertained only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted. Remember, pull requests are permitted only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted. (The one exception to this is trivial changes to the documentation or other non-critical resources.)

View File

@ -1,25 +1,25 @@
# NetBox Development # NetBox Development
NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox. NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Each pull request must be preceded by an **approved** issue. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
## Communication ## Communication
There are several official forums for communication among the developers and community members: There are several official forums for communication among the developers and community members:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue. * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. * [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions. * [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
## Governance ## Governance
NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions (in other words, avoid scope creep). NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions.
## Project Structure ## Project Structure
All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. Only pull requests representing new releases should be merged into `master`.
NetBox components are arranged into functional subsections called _apps_ (a carryover from Django vernacular). Each app holds the models, views, and templates relevant to a particular function: NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function:
* `circuits`: Communications circuits and providers (not to be confused with power circuits) * `circuits`: Communications circuits and providers (not to be confused with power circuits)
* `dcim`: Datacenter infrastructure management (sites, racks, and devices) * `dcim`: Datacenter infrastructure management (sites, racks, and devices)
@ -29,3 +29,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
* `users`: Authentication and user preferences * `users`: Authentication and user preferences
* `utilities`: Resources which are not user-facing (extendable classes, etc.) * `utilities`: Resources which are not user-facing (extendable classes, etc.)
* `virtualization`: Virtual machines and clusters * `virtualization`: Virtual machines and clusters
* `wireless`: Wireless links and LANs
All core functionality is stored within the `netbox/` subdirectory. HTML templates are stored in a common `templates/` directory, with model- and view-specific templates arranged by app. Documentation is kept in the `docs/` root directory.

View File

@ -17,12 +17,12 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* Nesting - These models can be nested recursively to create a hierarchy * Nesting - These models can be nested recursively to create a hierarchy
| Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting | | Type | Change Logging | Webhooks | Custom Fields | Export Templates | Tags | Journaling | Nesting |
| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | | ------------------ | ---------------- | ---------------- |------------------| ---------------- | ---------------- | ---------------- | ---------------- |
| Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | Primary | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | |
| Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Organizational | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: | | Nested Group | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | :material-check: |
| Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | | | Component | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | | |
| Component Template | :material-check: | :material-check: | :material-check: | | | | | | Component Template | :material-check: | :material-check: | | | | | |
## Models Index ## Models Index
@ -44,6 +44,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
* [ipam.ASN](../models/ipam/asn.md) * [ipam.ASN](../models/ipam/asn.md)
* [ipam.FHRPGroup](../models/ipam/fhrpgroup.md) * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
* [ipam.IPAddress](../models/ipam/ipaddress.md) * [ipam.IPAddress](../models/ipam/ipaddress.md)
* [ipam.IPRange](../models/ipam/iprange.md)
* [ipam.Prefix](../models/ipam/prefix.md) * [ipam.Prefix](../models/ipam/prefix.md)
* [ipam.RouteTarget](../models/ipam/routetarget.md) * [ipam.RouteTarget](../models/ipam/routetarget.md)
* [ipam.Service](../models/ipam/service.md) * [ipam.Service](../models/ipam/service.md)

View File

@ -1,6 +1,6 @@
# Style Guide # Style Guide
NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`. NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh` for details.
## PEP 8 Exceptions ## PEP 8 Exceptions
@ -30,7 +30,7 @@ pycodestyle --ignore=W504,E501 netbox/
## Introducing New Dependencies ## Introducing New Dependencies
The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks. The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks.
If there's a strong case for introducing a new dependency, it must meet the following criteria: If there's a strong case for introducing a new dependency, it must meet the following criteria:
@ -43,7 +43,7 @@ When adding a new dependency, a short description of the package and the URL of
## General Guidance ## General Guidance
* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point. * When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and submit a separate bug report so that the entire code base can be evaluated at a later point.
* Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it. * Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.

View File

@ -1,13 +1,21 @@
# NetBox v3.1 # NetBox v3.1
## v3.1.2 (FUTURE) ## v3.1.3 (FUTURE)
---
## v3.1.2 (2021-12-20)
### Enhancements ### Enhancements
* [#7661](https://github.com/netbox-community/netbox/issues/7661) - Remove forced styling of custom banners
* [#7665](https://github.com/netbox-community/netbox/issues/7665) - Add toggle to show only available child prefixes * [#7665](https://github.com/netbox-community/netbox/issues/7665) - Add toggle to show only available child prefixes
* [#7999](https://github.com/netbox-community/netbox/issues/7999) - Add 6 GHz and 60 GHz wireless channels
* [#8057](https://github.com/netbox-community/netbox/issues/8057) - Dynamic object tables using HTMX * [#8057](https://github.com/netbox-community/netbox/issues/8057) - Dynamic object tables using HTMX
* [#8080](https://github.com/netbox-community/netbox/issues/8080) - Link to NAT IPs for device/VM primary IPs * [#8080](https://github.com/netbox-community/netbox/issues/8080) - Link to NAT IPs for device/VM primary IPs
* [#8081](https://github.com/netbox-community/netbox/issues/8081) - Allow creating services directly from navigation menu * [#8081](https://github.com/netbox-community/netbox/issues/8081) - Allow creating services directly from navigation menu
* [#8083](https://github.com/netbox-community/netbox/issues/8083) - Removed "related devices" panel from device view
* [#8108](https://github.com/netbox-community/netbox/issues/8108) - Improve breadcrumb links for device/VM components
### Bug Fixes ### Bug Fixes
@ -16,6 +24,11 @@
* [#8077](https://github.com/netbox-community/netbox/issues/8077) - Fix exception when attaching image to location, circuit, or power panel * [#8077](https://github.com/netbox-community/netbox/issues/8077) - Fix exception when attaching image to location, circuit, or power panel
* [#8078](https://github.com/netbox-community/netbox/issues/8078) - Add missing wireless models to `lsmodels()` in `nbshell` * [#8078](https://github.com/netbox-community/netbox/issues/8078) - Add missing wireless models to `lsmodels()` in `nbshell`
* [#8079](https://github.com/netbox-community/netbox/issues/8079) - Fix validation of LLDP neighbors when connected device has an asset tag * [#8079](https://github.com/netbox-community/netbox/issues/8079) - Fix validation of LLDP neighbors when connected device has an asset tag
* [#8088](https://github.com/netbox-community/netbox/issues/8088) - Improve legibility of text in labels with light-colored backgrounds
* [#8092](https://github.com/netbox-community/netbox/issues/8092) - Rack elevations should not include device asset tags
* [#8096](https://github.com/netbox-community/netbox/issues/8096) - Fix DataError during change logging of objects with very long string representations
* [#8101](https://github.com/netbox-community/netbox/issues/8101) - Preserve return URL when using "create and add another" button
* [#8102](https://github.com/netbox-community/netbox/issues/8102) - Raise validation error when attempting to assign an IP address to multiple objects
--- ---

View File

@ -18,6 +18,10 @@ __all__ = (
) )
def get_device_name(device):
return device.name or str(device.device_type)
class RackElevationSVG: class RackElevationSVG:
""" """
Use this class to render a rack elevation as an SVG image. Use this class to render a rack elevation as an SVG image.
@ -85,7 +89,7 @@ class RackElevationSVG:
return drawing return drawing
def _draw_device_front(self, drawing, device, start, end, text): def _draw_device_front(self, drawing, device, start, end, text):
name = str(device) name = get_device_name(device)
if device.devicebay_count: if device.devicebay_count:
name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count) name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
@ -120,7 +124,7 @@ class RackElevationSVG:
rect = drawing.rect(start, end, class_="slot blocked") rect = drawing.rect(start, end, class_="slot blocked")
rect.set_desc(self._get_device_description(device)) rect.set_desc(self._get_device_description(device))
drawing.add(rect) drawing.add(rect)
drawing.add(drawing.text(str(device), insert=text)) drawing.add(drawing.text(get_device_name(device), insert=text))
# Embed rear device type image if one exists # Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image: if self.include_images and device.device_type.rear_image:
@ -132,9 +136,9 @@ class RackElevationSVG:
) )
image.fit(scale='slice') image.fit(scale='slice')
drawing.add(image) drawing.add(image)
drawing.add(drawing.text(str(device), insert=text, stroke='black', drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label')) drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
@staticmethod @staticmethod
def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation): def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

View File

@ -1491,19 +1491,9 @@ class DeviceView(generic.ObjectView):
# Services # Services
services = Service.objects.restrict(request.user, 'view').filter(device=instance) services = Service.objects.restrict(request.user, 'view').filter(device=instance)
# Find up to ten devices in the same site with the same functional role for quick reference.
related_devices = Device.objects.restrict(request.user, 'view').filter(
site=instance.site, device_role=instance.device_role
).exclude(
pk=instance.pk
).prefetch_related(
'rack', 'device_type__manufacturer'
)[:10]
return { return {
'services': services, 'services': services,
'vc_members': vc_members, 'vc_members': vc_members,
'related_devices': related_devices,
'active_tab': 'device', 'active_tab': 'device',
} }

View File

@ -462,12 +462,15 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
super().clean() super().clean()
# Handle object assignment # Handle object assignment
if self.cleaned_data['interface']: selected_objects = [
self.instance.assigned_object = self.cleaned_data['interface'] field for field in ('interface', 'vminterface', 'fhrpgroup') if self.cleaned_data[field]
elif self.cleaned_data['vminterface']: ]
self.instance.assigned_object = self.cleaned_data['vminterface'] if len(selected_objects) > 1:
elif self.cleaned_data['fhrpgroup']: raise forms.ValidationError({
self.instance.assigned_object = self.cleaned_data['fhrpgroup'] selected_objects[1]: "An IP address can only be assigned to a single object."
})
elif selected_objects:
self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
# Primary IP assignment is only available if an interface has been assigned. # Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')

View File

@ -58,13 +58,11 @@ class FHRPGroup(PrimaryModel):
def __str__(self): def __str__(self):
name = f'{self.get_protocol_display()}: {self.group_id}' name = f'{self.get_protocol_display()}: {self.group_id}'
# Append the list of assigned IP addresses to serve as an additional identifier # Append the first assigned IP addresses (if any) to serve as an additional identifier
if self.pk: if self.pk:
ip_addresses = [ ip_address = self.ip_addresses.first()
str(ip.address) for ip in self.ip_addresses.all() if ip_address:
] return f"{name} ({ip_address})"
if ip_addresses:
return f"{name} ({', '.join(ip_addresses)})"
return name return name

View File

@ -62,7 +62,7 @@ class ChangeLoggingMixin(models.Model):
objectchange = ObjectChange( objectchange = ObjectChange(
changed_object=self, changed_object=self,
related_object=related_object, related_object=related_object,
object_repr=str(self), object_repr=str(self)[:200],
action=action action=action
) )
if hasattr(self, '_prechange_snapshot'): if hasattr(self, '_prechange_snapshot'):

View File

@ -644,7 +644,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
if not selected_objects: if not selected_objects:
messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural)) messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
return redirect(self.get_return_url(request)) return redirect(self.get_return_url(request))
table = self.table(selected_objects) table = self.table(selected_objects, orderable=False)
if '_create' in request.POST: if '_create' in request.POST:
form = self.form(request.POST) form = self.form(request.POST)

View File

@ -557,8 +557,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
redirect_url = request.path redirect_url = request.path
# If the object has clone_fields, pre-populate a new instance of the form # If the object has clone_fields, pre-populate a new instance of the form
if hasattr(obj, 'clone_fields'): params = prepare_cloned_fields(obj)
redirect_url += f"?{prepare_cloned_fields(obj)}" if 'return_url' in request.GET:
params['return_url'] = request.GET.get('return_url')
if params:
redirect_url += f"?{params.urlencode()}"
return redirect(redirect_url) return redirect(redirect_url)

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -124,6 +124,7 @@
onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'" onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'"
/> />
<link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" /> <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
<link rel="apple-touch-icon" type="image/png" href="{% static 'netbox_touch-icon-180.png' %}" />
{# Javascript #} {# Javascript #}
<script <script

View File

@ -59,7 +59,7 @@
</nav> </nav>
{% if config.BANNER_TOP %} {% if config.BANNER_TOP %}
<div class="alert alert-info text-center mx-3" role="alert"> <div class="text-center mx-3">
{{ config.BANNER_TOP|safe }} {{ config.BANNER_TOP|safe }}
</div> </div>
{% endif %} {% endif %}
@ -99,7 +99,7 @@
</div> </div>
{% if config.BANNER_BOTTOM %} {% if config.BANNER_BOTTOM %}
<div class="alert alert-info text-center mx-3" role="alert"> <div class="text-center mx-3">
{{ config.BANNER_BOTTOM|safe }} {{ config.BANNER_BOTTOM|safe }}
</div> </div>
{% endif %} {% endif %}

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_consoleports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_consoleserverports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -148,6 +148,12 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
Management Management
@ -220,12 +226,6 @@
</table> </table>
</div> </div>
</div> </div>
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-md-6">
{% if object.powerports.exists and object.poweroutlets.exists %} {% if object.powerports.exists and object.poweroutlets.exists %}
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">
@ -298,39 +298,6 @@
</div> </div>
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}
{% include 'inc/panels/image_attachments.html' %} {% include 'inc/panels/image_attachments.html' %}
<div class="card noprint">
<h5 class="card-header">
Related Devices
</h5>
<div class="card-body">
{% if related_devices %}
<table class="table table-hover">
<tr>
<th>Device</th>
<th>Rack</th>
<th>Type</th>
</tr>
{% for rd in related_devices %}
<tr>
<td>
<a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
</td>
<td>
{% if rd.rack %}
<a href="{% url 'dcim:rack' pk=rd.rack.pk %}">{{ rd.rack }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td>{{ rd.device_type }}</td>
</tr>
{% endfor %}
</table>
{% else %}
<div class="text-muted">None</div>
{% endif %}
</div>
</div>
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -1,9 +0,0 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load perms %}
{% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}?device_id={{ object.device.pk }}">{{ object.device }}</a></li>
{% endblock %}

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_devicebays' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_frontports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,8 +1,15 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_interfaces' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block extra_controls %} {% block extra_controls %}
{% if perms.dcim.add_interface and not object.is_virtual %} {% if perms.dcim.add_interface and not object.is_virtual %}
<a href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success"> <a href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_inventory' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_modulebays' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_poweroutlets' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_powerports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,7 +1,14 @@
{% extends 'dcim/device_component.html' %} {% extends 'generic/object.html' %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% block breadcrumbs %}
{{ block.super }}
<li class="breadcrumb-item">
<a href="{% url 'dcim:device_rearports' pk=object.device.pk %}">{{ object.device }}</a>
</li>
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">

View File

@ -1,41 +1,73 @@
{% extends 'base/layout.html' %} {% extends 'base/layout.html' %}
{% load helpers %}
{% load form_helpers %} {% load form_helpers %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block title %}Add {{ model_name|title }}{% endblock %} {% block title %}Add {{ model_name|title }}{% endblock %}
{% block content %} {% block tabs %}
<p>{{ table.rows|length }} {{ parent_model_name }} selected</p> <ul class="nav nav-tabs px-3">
<form action="." method="post" class="form form-horizontal"> <li class="nav-item" role="presentation">
{% csrf_token %} <button class="nav-link active" id="component-form-tab" data-bs-toggle="tab" data-bs-target="#component-form" type="button" role="tab" aria-controls="component-form" aria-selected="true">
{% if request.POST.return_url %} Bulk Creation
<input type="hidden" name="return_url" value="{{ request.POST.return_url }}" /> </button>
{% endif %} </li>
{% for field in form.hidden_fields %} <li class="nav-item" role="presentation">
{{ field }} <button class="nav-link" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="object-list" aria-selected="false">
{% endfor %} Selected Objects
<div class="row"> {% badge table.rows|length %}
<div class="col col-md-7"> </button>
<div class="table-responsive"> </li>
{% render_table table 'inc/table.html' %} </ul>
{% endblock %}
{% block content-wrapper %}
<div class="tab-content">
{% block content %}
{# Component creation form #}
<div class="tab-pane show active" id="component-form" role="tabpanel" aria-labelledby="component-form-tab">
<form action="" method="post" class="form form-horizontal">
{% 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 %}
<div class="row">
<div class="col col-md-12 col-lg-10 offset-lg-1">
<div class="card">
<h5 class="card-header">{{ model_name|title }} to Add</h5>
<div class="card-body">
{% for field in form.visible_fields %}
{% render_field field %}
{% endfor %}
</div>
</div>
<div class="form-group text-end">
<div class="col col-md-12">
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_create" class="btn btn-primary">Create</button>
</div>
</div>
</div> </div>
</div>
</form>
</div>
{# Selected objects list #}
<div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
<div class="card">
<div class="card-body table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div> </div>
<div class="col col-md-5"> </div>
<div class="card">
<h5 class="card-header">{{ model_name|title }} to Add</h5> {% endblock %}
<div class="card-body"> </div>
{% for field in form.visible_fields %} {% endblock %}
{% render_field field %}
{% endfor %}
</div>
</div>
<div class="form-group text-end">
<div class="col col-md-12">
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
<button type="submit" name="_create" class="btn btn-primary">Create</button>
</div>
</div>
</div>
</div>
</form>
{% endblock content %}

View File

@ -39,14 +39,17 @@
<div class="row"> <div class="row">
<div class="col col-md-12 col-lg-10 offset-lg-1"> <div class="col col-md-12 col-lg-10 offset-lg-1">
{% for field in form.visible_fields %} <div class="card">
{% if field.name in form.nullable_fields %} <div class="card-body">
{% for field in form.visible_fields %}
{% if field.name in form.nullable_fields %}
{% render_field field bulk_nullable=True %} {% render_field field bulk_nullable=True %}
{% else %} {% else %}
{% render_field field %} {% render_field field %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</div>
</div>
<div class="text-end"> <div class="text-end">
<a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a> <a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
@ -60,8 +63,10 @@
{# Selected objects list #} {# Selected objects list #}
<div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab"> <div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
<div class="table-responsive"> <div class="card">
{% render_table table 'inc/table.html' %} <div class="card-body table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,7 @@
{% block title %}Assign an IP Address{% endblock title %} {% block title %}Assign an IP Address{% endblock title %}
{% block tabs %} {% block tabs %}
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %} {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='assign' %}
{% endblock %} {% endblock %}
{% block form %} {% block form %}

View File

@ -5,7 +5,7 @@
{% block title %}Bulk Add IP Addresses{% endblock %} {% block title %}Bulk Add IP Addresses{% endblock %}
{% block tabs %} {% block tabs %}
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='bulk_add' %} {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='bulk_add' %}
{% endblock %} {% endblock %}
{% block form %} {% block form %}

View File

@ -4,7 +4,7 @@
{% load helpers %} {% load helpers %}
{% block tabs %} {% block tabs %}
{% include 'ipam/inc/ipadress_edit_header.html' with active_tab='add' %} {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='add' %}
{% endblock tabs %} {% endblock tabs %}
{% block form %} {% block form %}

View File

@ -8,7 +8,7 @@
{# Login banner #} {# Login banner #}
{% if config.BANNER_LOGIN %} {% if config.BANNER_LOGIN %}
<div class="alert alert-secondary mw-90 mw-md-75 mw-lg-80 mw-xl-75 mw-xxl-50" role="alert"> <div class="mw-90 mw-md-75 mw-lg-80 mw-xl-75 mw-xxl-50">
{{ config.BANNER_LOGIN|safe }} {{ config.BANNER_LOGIN|safe }}
</div> </div>
{% endif %} {% endif %}

View File

@ -60,32 +60,6 @@
</div> </div>
</div> </div>
{% elif field|widget_type == 'selectspeedwidget' %}
{# This is outside the widget because bootstrap requires a specific order for border-radius purposes. #}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end{% if field.field.required %} required{% endif %}" for="{{ field.id_for_label }}">
{{ field.label }}
</label>
<div class="col">
<div class="input-group">
{{ field }}
<button type="button" class="btn btn-outline-dark border-input dropdown-toggle" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a href="#" target="{{ field.id_for_label }}" data="10000" class="set_speed dropdown-item">10 Mbps</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="100000" class="set_speed dropdown-item">100 Mbps</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="1000000" class="set_speed dropdown-item">1 Gbps</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="10000000" class="set_speed dropdown-item">10 Gbps</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="25000000" class="set_speed dropdown-item">25 Gbps</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="40000000" class="set_speed dropdown-item">40 Gbps</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="100000000" class="set_speed dropdown-item">100 Gbps</a></li>
<li><hr class="dropdown-divider"/></li>
<li><a href="#" target="{{ field.id_for_label }}" data="1544" class="set_speed dropdown-item">T1 (1.544 Mbps)</a></li>
<li><a href="#" target="{{ field.id_for_label }}" data="2048" class="set_speed dropdown-item">E1 (2.048 Mbps)</a></li>
</ul>
</div>
</div>
</div>
{% elif field|widget_type == 'fileinput' %} {% elif field|widget_type == 'fileinput' %}
<div class="input-group mb-3"> <div class="input-group mb-3">
<input <input

View File

@ -5,7 +5,9 @@
{% block breadcrumbs %} {% block breadcrumbs %}
{{ block.super }} {{ block.super }}
<li class="breadcrumb-item"><a href="{% url 'virtualization:vminterface_list' %}?virtual_machine_id={{ object.virtual_machine.pk }}">{{ object.virtual_machine }}</a></li> <li class="breadcrumb-item">
<a href="{% url 'virtualization:virtualmachine_interfaces' pk=object.virtual_machine.pk %}">{{ object.virtual_machine }}</a>
</li>
{% endblock %} {% endblock %}
{% block content %} {% block content %}

View File

@ -1 +1,16 @@
{% include 'django/forms/widgets/number.html' %} <div class="input-group">
{% include 'django/forms/widgets/number.html' %}
<button type="button" class="btn btn-outline-dark border-input dropdown-toggle" data-bs-toggle="dropdown"></button>
<ul class="dropdown-menu dropdown-menu-end">
<li><a href="#" target="id_{{ widget.name }}" data="10000" class="set_speed dropdown-item">10 Mbps</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="100000" class="set_speed dropdown-item">100 Mbps</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="1000000" class="set_speed dropdown-item">1 Gbps</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="10000000" class="set_speed dropdown-item">10 Gbps</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="25000000" class="set_speed dropdown-item">25 Gbps</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="40000000" class="set_speed dropdown-item">40 Gbps</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="100000000" class="set_speed dropdown-item">100 Gbps</a></li>
<li><hr class="dropdown-divider"/></li>
<li><a href="#" target="id_{{ widget.name }}" data="1544" class="set_speed dropdown-item">T1 (1.544 Mbps)</a></li>
<li><a href="#" target="id_{{ widget.name }}" data="2048" class="set_speed dropdown-item">E1 (2.048 Mbps)</a></li>
</ul>
</div>

View File

@ -30,7 +30,7 @@ def clone_button(instance):
url = reverse(_get_viewname(instance, 'add')) url = reverse(_get_viewname(instance, 'add'))
# Populate cloned field values # Populate cloned field values
param_string = prepare_cloned_fields(instance) param_string = prepare_cloned_fields(instance).urlencode()
if param_string: if param_string:
url = f'{url}?{param_string}' url = f'{url}?{param_string}'

View File

@ -233,7 +233,7 @@ def fgcolor(value):
value = value.lower().strip('#') value = value.lower().strip('#')
if not re.match('^[0-9a-f]{6}$', value): if not re.match('^[0-9a-f]{6}$', value):
return '' return ''
return '#{}'.format(foreground_color(value)) return f'#{foreground_color(value)}'
@register.filter() @register.filter()

View File

@ -8,6 +8,7 @@ from typing import Any, Dict, List, Tuple
from django.core.serializers import serialize from django.core.serializers import serialize
from django.db.models import Count, OuterRef, Subquery from django.db.models import Count, OuterRef, Subquery
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.http import QueryDict
from jinja2.sandbox import SandboxedEnvironment from jinja2.sandbox import SandboxedEnvironment
from mptt.models import MPTTModel from mptt.models import MPTTModel
@ -53,9 +54,10 @@ def foreground_color(bg_color, dark='000000', light='ffffff'):
:param dark: RBG color code for dark text :param dark: RBG color code for dark text
:param light: RBG color code for light text :param light: RBG color code for light text
""" """
THRESHOLD = 150
bg_color = bg_color.strip('#') bg_color = bg_color.strip('#')
r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)] r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
if r * 0.299 + g * 0.587 + b * 0.114 > 186: if r * 0.299 + g * 0.587 + b * 0.114 > THRESHOLD:
return dark return dark
else: else:
return light return light
@ -248,10 +250,8 @@ def prepare_cloned_fields(instance):
for tag in instance.tags.all(): for tag in instance.tags.all():
params.append(('tags', tag.pk)) params.append(('tags', tag.pk))
# Concatenate parameters into a URL query string # Return a QueryDict with the parameters
param_string = '&'.join([f'{k}={v}' for k, v in params]) return QueryDict('&'.join([f'{k}={v}' for k, v in params]), mutable=True)
return param_string
def shallow_compare_dict(source_dict, destination_dict, exclude=None): def shallow_compare_dict(source_dict, destination_dict, exclude=None):

View File

@ -84,6 +84,140 @@ class WirelessChannelChoices(ChoiceSet):
CHANNEL_5G_175 = '5g-175-5875-40' CHANNEL_5G_175 = '5g-175-5875-40'
CHANNEL_5G_177 = '5g-177-5885-20' CHANNEL_5G_177 = '5g-177-5885-20'
# 6 GHz
CHANNEL_6G_1 = '6g-1-5955-20'
CHANNEL_6G_3 = '6g-3-5965-40'
CHANNEL_6G_5 = '6g-5-5975-20'
CHANNEL_6G_7 = '6g-7-5985-80'
CHANNEL_6G_9 = '6g-9-5995-20'
CHANNEL_6G_11 = '6g-11-6005-40'
CHANNEL_6G_13 = '6g-13-6015-20'
CHANNEL_6G_15 = '6g-15-6025-160'
CHANNEL_6G_17 = '6g-17-6035-20'
CHANNEL_6G_19 = '6g-19-6045-40'
CHANNEL_6G_21 = '6g-21-6055-20'
CHANNEL_6G_23 = '6g-23-6065-80'
CHANNEL_6G_25 = '6g-25-6075-20'
CHANNEL_6G_27 = '6g-27-6085-40'
CHANNEL_6G_29 = '6g-29-6095-20'
CHANNEL_6G_31 = '6g-31-6105-320'
CHANNEL_6G_33 = '6g-33-6115-20'
CHANNEL_6G_35 = '6g-35-6125-40'
CHANNEL_6G_37 = '6g-37-6135-20'
CHANNEL_6G_39 = '6g-39-6145-80'
CHANNEL_6G_41 = '6g-41-6155-20'
CHANNEL_6G_43 = '6g-43-6165-40'
CHANNEL_6G_45 = '6g-45-6175-20'
CHANNEL_6G_47 = '6g-47-6185-160'
CHANNEL_6G_49 = '6g-49-6195-20'
CHANNEL_6G_51 = '6g-51-6205-40'
CHANNEL_6G_53 = '6g-53-6215-20'
CHANNEL_6G_55 = '6g-55-6225-80'
CHANNEL_6G_57 = '6g-57-6235-20'
CHANNEL_6G_59 = '6g-59-6245-40'
CHANNEL_6G_61 = '6g-61-6255-20'
CHANNEL_6G_65 = '6g-65-6275-20'
CHANNEL_6G_67 = '6g-67-6285-40'
CHANNEL_6G_69 = '6g-69-6295-20'
CHANNEL_6G_71 = '6g-71-6305-80'
CHANNEL_6G_73 = '6g-73-6315-20'
CHANNEL_6G_75 = '6g-75-6325-40'
CHANNEL_6G_77 = '6g-77-6335-20'
CHANNEL_6G_79 = '6g-79-6345-160'
CHANNEL_6G_81 = '6g-81-6355-20'
CHANNEL_6G_83 = '6g-83-6365-40'
CHANNEL_6G_85 = '6g-85-6375-20'
CHANNEL_6G_87 = '6g-87-6385-80'
CHANNEL_6G_89 = '6g-89-6395-20'
CHANNEL_6G_91 = '6g-91-6405-40'
CHANNEL_6G_93 = '6g-93-6415-20'
CHANNEL_6G_95 = '6g-95-6425-320'
CHANNEL_6G_97 = '6g-97-6435-20'
CHANNEL_6G_99 = '6g-99-6445-40'
CHANNEL_6G_101 = '6g-101-6455-20'
CHANNEL_6G_103 = '6g-103-6465-80'
CHANNEL_6G_105 = '6g-105-6475-20'
CHANNEL_6G_107 = '6g-107-6485-40'
CHANNEL_6G_109 = '6g-109-6495-20'
CHANNEL_6G_111 = '6g-111-6505-160'
CHANNEL_6G_113 = '6g-113-6515-20'
CHANNEL_6G_115 = '6g-115-6525-40'
CHANNEL_6G_117 = '6g-117-6535-20'
CHANNEL_6G_119 = '6g-119-6545-80'
CHANNEL_6G_121 = '6g-121-6555-20'
CHANNEL_6G_123 = '6g-123-6565-40'
CHANNEL_6G_125 = '6g-125-6575-20'
CHANNEL_6G_129 = '6g-129-6595-20'
CHANNEL_6G_131 = '6g-131-6605-40'
CHANNEL_6G_133 = '6g-133-6615-20'
CHANNEL_6G_135 = '6g-135-6625-80'
CHANNEL_6G_137 = '6g-137-6635-20'
CHANNEL_6G_139 = '6g-139-6645-40'
CHANNEL_6G_141 = '6g-141-6655-20'
CHANNEL_6G_143 = '6g-143-6665-160'
CHANNEL_6G_145 = '6g-145-6675-20'
CHANNEL_6G_147 = '6g-147-6685-40'
CHANNEL_6G_149 = '6g-149-6695-20'
CHANNEL_6G_151 = '6g-151-6705-80'
CHANNEL_6G_153 = '6g-153-6715-20'
CHANNEL_6G_155 = '6g-155-6725-40'
CHANNEL_6G_157 = '6g-157-6735-20'
CHANNEL_6G_159 = '6g-159-6745-320'
CHANNEL_6G_161 = '6g-161-6755-20'
CHANNEL_6G_163 = '6g-163-6765-40'
CHANNEL_6G_165 = '6g-165-6775-20'
CHANNEL_6G_167 = '6g-167-6785-80'
CHANNEL_6G_169 = '6g-169-6795-20'
CHANNEL_6G_171 = '6g-171-6805-40'
CHANNEL_6G_173 = '6g-173-6815-20'
CHANNEL_6G_175 = '6g-175-6825-160'
CHANNEL_6G_177 = '6g-177-6835-20'
CHANNEL_6G_179 = '6g-179-6845-40'
CHANNEL_6G_181 = '6g-181-6855-20'
CHANNEL_6G_183 = '6g-183-6865-80'
CHANNEL_6G_185 = '6g-185-6875-20'
CHANNEL_6G_187 = '6g-187-6885-40'
CHANNEL_6G_189 = '6g-189-6895-20'
CHANNEL_6G_193 = '6g-193-6915-20'
CHANNEL_6G_195 = '6g-195-6925-40'
CHANNEL_6G_197 = '6g-197-6935-20'
CHANNEL_6G_199 = '6g-199-6945-80'
CHANNEL_6G_201 = '6g-201-6955-20'
CHANNEL_6G_203 = '6g-203-6965-40'
CHANNEL_6G_205 = '6g-205-6975-20'
CHANNEL_6G_207 = '6g-207-6985-160'
CHANNEL_6G_209 = '6g-209-6995-20'
CHANNEL_6G_211 = '6g-211-7005-40'
CHANNEL_6G_213 = '6g-213-7015-20'
CHANNEL_6G_215 = '6g-215-7025-80'
CHANNEL_6G_217 = '6g-217-7035-20'
CHANNEL_6G_219 = '6g-219-7045-40'
CHANNEL_6G_221 = '6g-221-7055-20'
CHANNEL_6G_225 = '6g-225-7075-20'
CHANNEL_6G_227 = '6g-227-7085-40'
CHANNEL_6G_229 = '6g-229-7095-20'
CHANNEL_6G_233 = '6g-233-7115-20'
# 60 GHz
CHANNEL_60G_1 = '60g-1-58320-2160'
CHANNEL_60G_2 = '60g-2-60480-2160'
CHANNEL_60G_3 = '60g-3-62640-2160'
CHANNEL_60G_4 = '60g-4-64800-2160'
CHANNEL_60G_5 = '60g-5-66960-2160'
CHANNEL_60G_6 = '60g-6-69120-2160'
CHANNEL_60G_9 = '60g-9-59400-4320'
CHANNEL_60G_10 = '60g-10-61560-4320'
CHANNEL_60G_11 = '60g-11-63720-4320'
CHANNEL_60G_12 = '60g-12-65880-4320'
CHANNEL_60G_13 = '60g-13-68040-4320'
CHANNEL_60G_17 = '60g-17-60480-6480'
CHANNEL_60G_18 = '60g-18-62640-6480'
CHANNEL_60G_19 = '60g-19-64800-6480'
CHANNEL_60G_20 = '60g-20-66960-6480'
CHANNEL_60G_25 = '60g-25-61560-6480'
CHANNEL_60G_26 = '60g-26-63720-6480'
CHANNEL_60G_27 = '60g-27-65880-6480'
CHOICES = ( CHOICES = (
( (
'2.4 GHz (802.11b/g/n/ax)', '2.4 GHz (802.11b/g/n/ax)',
@ -162,6 +296,146 @@ class WirelessChannelChoices(ChoiceSet):
(CHANNEL_5G_177, '177 (5885/20 MHz)'), (CHANNEL_5G_177, '177 (5885/20 MHz)'),
) )
), ),
(
'6 GHz (802.11ax)',
(
(CHANNEL_6G_1, '1 (5955/20 MHz)'),
(CHANNEL_6G_3, '3 (5965/40 MHz)'),
(CHANNEL_6G_5, '5 (5975/20 MHz)'),
(CHANNEL_6G_7, '7 (5985/80 MHz)'),
(CHANNEL_6G_9, '9 (5995/20 MHz)'),
(CHANNEL_6G_11, '11 (6005/40 MHz)'),
(CHANNEL_6G_13, '13 (6015/20 MHz)'),
(CHANNEL_6G_15, '15 (6025/160 MHz)'),
(CHANNEL_6G_17, '17 (6035/20 MHz)'),
(CHANNEL_6G_19, '19 (6045/40 MHz)'),
(CHANNEL_6G_21, '21 (6055/20 MHz)'),
(CHANNEL_6G_23, '23 (6065/80 MHz)'),
(CHANNEL_6G_25, '25 (6075/20 MHz)'),
(CHANNEL_6G_27, '27 (6085/40 MHz)'),
(CHANNEL_6G_29, '29 (6095/20 MHz)'),
(CHANNEL_6G_31, '31 (6105/320 MHz)'),
(CHANNEL_6G_33, '33 (6115/20 MHz)'),
(CHANNEL_6G_35, '35 (6125/40 MHz)'),
(CHANNEL_6G_37, '37 (6135/20 MHz)'),
(CHANNEL_6G_39, '39 (6145/80 MHz)'),
(CHANNEL_6G_41, '41 (6155/20 MHz)'),
(CHANNEL_6G_43, '43 (6165/40 MHz)'),
(CHANNEL_6G_45, '45 (6175/20 MHz)'),
(CHANNEL_6G_47, '47 (6185/160 MHz)'),
(CHANNEL_6G_49, '49 (6195/20 MHz)'),
(CHANNEL_6G_51, '51 (6205/40 MHz)'),
(CHANNEL_6G_53, '53 (6215/20 MHz)'),
(CHANNEL_6G_55, '55 (6225/80 MHz)'),
(CHANNEL_6G_57, '57 (6235/20 MHz)'),
(CHANNEL_6G_59, '59 (6245/40 MHz)'),
(CHANNEL_6G_61, '61 (6255/20 MHz)'),
(CHANNEL_6G_65, '65 (6275/20 MHz)'),
(CHANNEL_6G_67, '67 (6285/40 MHz)'),
(CHANNEL_6G_69, '69 (6295/20 MHz)'),
(CHANNEL_6G_71, '71 (6305/80 MHz)'),
(CHANNEL_6G_73, '73 (6315/20 MHz)'),
(CHANNEL_6G_75, '75 (6325/40 MHz)'),
(CHANNEL_6G_77, '77 (6335/20 MHz)'),
(CHANNEL_6G_79, '79 (6345/160 MHz)'),
(CHANNEL_6G_81, '81 (6355/20 MHz)'),
(CHANNEL_6G_83, '83 (6365/40 MHz)'),
(CHANNEL_6G_85, '85 (6375/20 MHz)'),
(CHANNEL_6G_87, '87 (6385/80 MHz)'),
(CHANNEL_6G_89, '89 (6395/20 MHz)'),
(CHANNEL_6G_91, '91 (6405/40 MHz)'),
(CHANNEL_6G_93, '93 (6415/20 MHz)'),
(CHANNEL_6G_95, '95 (6425/320 MHz)'),
(CHANNEL_6G_97, '97 (6435/20 MHz)'),
(CHANNEL_6G_99, '99 (6445/40 MHz)'),
(CHANNEL_6G_101, '101 (6455/20 MHz)'),
(CHANNEL_6G_103, '103 (6465/80 MHz)'),
(CHANNEL_6G_105, '105 (6475/20 MHz)'),
(CHANNEL_6G_107, '107 (6485/40 MHz)'),
(CHANNEL_6G_109, '109 (6495/20 MHz)'),
(CHANNEL_6G_111, '111 (6505/160 MHz)'),
(CHANNEL_6G_113, '113 (6515/20 MHz)'),
(CHANNEL_6G_115, '115 (6525/40 MHz)'),
(CHANNEL_6G_117, '117 (6535/20 MHz)'),
(CHANNEL_6G_119, '119 (6545/80 MHz)'),
(CHANNEL_6G_121, '121 (6555/20 MHz)'),
(CHANNEL_6G_123, '123 (6565/40 MHz)'),
(CHANNEL_6G_125, '125 (6575/20 MHz)'),
(CHANNEL_6G_129, '129 (6595/20 MHz)'),
(CHANNEL_6G_131, '131 (6605/40 MHz)'),
(CHANNEL_6G_133, '133 (6615/20 MHz)'),
(CHANNEL_6G_135, '135 (6625/80 MHz)'),
(CHANNEL_6G_137, '137 (6635/20 MHz)'),
(CHANNEL_6G_139, '139 (6645/40 MHz)'),
(CHANNEL_6G_141, '141 (6655/20 MHz)'),
(CHANNEL_6G_143, '143 (6665/160 MHz)'),
(CHANNEL_6G_145, '145 (6675/20 MHz)'),
(CHANNEL_6G_147, '147 (6685/40 MHz)'),
(CHANNEL_6G_149, '149 (6695/20 MHz)'),
(CHANNEL_6G_151, '151 (6705/80 MHz)'),
(CHANNEL_6G_153, '153 (6715/20 MHz)'),
(CHANNEL_6G_155, '155 (6725/40 MHz)'),
(CHANNEL_6G_157, '157 (6735/20 MHz)'),
(CHANNEL_6G_159, '159 (6745/320 MHz)'),
(CHANNEL_6G_161, '161 (6755/20 MHz)'),
(CHANNEL_6G_163, '163 (6765/40 MHz)'),
(CHANNEL_6G_165, '165 (6775/20 MHz)'),
(CHANNEL_6G_167, '167 (6785/80 MHz)'),
(CHANNEL_6G_169, '169 (6795/20 MHz)'),
(CHANNEL_6G_171, '171 (6805/40 MHz)'),
(CHANNEL_6G_173, '173 (6815/20 MHz)'),
(CHANNEL_6G_175, '175 (6825/160 MHz)'),
(CHANNEL_6G_177, '177 (6835/20 MHz)'),
(CHANNEL_6G_179, '179 (6845/40 MHz)'),
(CHANNEL_6G_181, '181 (6855/20 MHz)'),
(CHANNEL_6G_183, '183 (6865/80 MHz)'),
(CHANNEL_6G_185, '185 (6875/20 MHz)'),
(CHANNEL_6G_187, '187 (6885/40 MHz)'),
(CHANNEL_6G_189, '189 (6895/20 MHz)'),
(CHANNEL_6G_193, '193 (6915/20 MHz)'),
(CHANNEL_6G_195, '195 (6925/40 MHz)'),
(CHANNEL_6G_197, '197 (6935/20 MHz)'),
(CHANNEL_6G_199, '199 (6945/80 MHz)'),
(CHANNEL_6G_201, '201 (6955/20 MHz)'),
(CHANNEL_6G_203, '203 (6965/40 MHz)'),
(CHANNEL_6G_205, '205 (6975/20 MHz)'),
(CHANNEL_6G_207, '207 (6985/160 MHz)'),
(CHANNEL_6G_209, '209 (6995/20 MHz)'),
(CHANNEL_6G_211, '211 (7005/40 MHz)'),
(CHANNEL_6G_213, '213 (7015/20 MHz)'),
(CHANNEL_6G_215, '215 (7025/80 MHz)'),
(CHANNEL_6G_217, '217 (7035/20 MHz)'),
(CHANNEL_6G_219, '219 (7045/40 MHz)'),
(CHANNEL_6G_221, '221 (7055/20 MHz)'),
(CHANNEL_6G_225, '225 (7075/20 MHz)'),
(CHANNEL_6G_227, '227 (7085/40 MHz)'),
(CHANNEL_6G_229, '229 (7095/20 MHz)'),
(CHANNEL_6G_233, '233 (7115/20 MHz)'),
)
),
(
'60 GHz (802.11ad/ay)',
(
(CHANNEL_60G_1, '1 (58.32/2.16 GHz)'),
(CHANNEL_60G_2, '2 (60.48/2.16 GHz)'),
(CHANNEL_60G_3, '3 (62.64/2.16 GHz)'),
(CHANNEL_60G_4, '4 (64.80/2.16 GHz)'),
(CHANNEL_60G_5, '5 (66.96/2.16 GHz)'),
(CHANNEL_60G_6, '6 (69.12/2.16 GHz)'),
(CHANNEL_60G_9, '9 (59.40/4.32 GHz)'),
(CHANNEL_60G_10, '10 (61.56/4.32 GHz)'),
(CHANNEL_60G_11, '11 (63.72/4.32 GHz)'),
(CHANNEL_60G_12, '12 (65.88/4.32 GHz)'),
(CHANNEL_60G_13, '13 (68.04/4.32 GHz)'),
(CHANNEL_60G_17, '17 (60.48/6.48 GHz)'),
(CHANNEL_60G_18, '18 (62.64/6.48 GHz)'),
(CHANNEL_60G_19, '19 (64.80/6.48 GHz)'),
(CHANNEL_60G_20, '20 (66.96/6.48 GHz)'),
(CHANNEL_60G_25, '25 (61.56/8.64 GHz)'),
(CHANNEL_60G_26, '26 (63.72/8.64 GHz)'),
(CHANNEL_60G_27, '27 (65.88/8.64 GHz)'),
)
),
) )

View File

@ -1,11 +1,11 @@
Django==3.2.10 Django==3.2.10
django-cors-headers==3.10.1 django-cors-headers==3.10.1
django-debug-toolbar==3.2.3 django-debug-toolbar==3.2.4
django-filter==21.1 django-filter==21.1
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.4 django-mptt==0.13.4
django-pglocks==1.0.4 django-pglocks==1.0.4
django-prometheus==2.1.0 django-prometheus==2.2.0
django-redis==5.1.0 django-redis==5.1.0
django-rq==2.5.1 django-rq==2.5.1
django-tables2==2.4.1 django-tables2==2.4.1
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3 Jinja2==3.0.3
Markdown==3.3.6 Markdown==3.3.6
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.1.0 mkdocs-material==8.1.3
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.4.0 Pillow==8.4.0
psycopg2-binary==2.9.2 psycopg2-binary==2.9.2