diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 3844b6753..11b7e9aff 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.0 + placeholder: v3.3.4 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 3c7638f42..bc00a3921 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.3.0 + placeholder: v3.3.4 validations: required: true - type: dropdown diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a026878bb..33134cb45 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,15 +1,16 @@ -### Fixes: +### Fixes: #1234 + diff --git a/.github/workflows/lock.yml b/.github/workflows/lock.yml index b6073a71b..9df4bc441 100644 --- a/.github/workflows/lock.yml +++ b/.github/workflows/lock.yml @@ -9,13 +9,9 @@ jobs: lock: runs-on: ubuntu-latest steps: - - uses: dessant/lock-threads@v2 + - uses: dessant/lock-threads@v3 with: github-token: ${{ github.token }} - issue-lock-inactive-days: '90' - issue-exclude-created-before: '' - issue-exclude-labels: '' - issue-lock-labels: '' - issue-lock-comment: '' + issue-inactive-days: 90 + pr-inactive-days: 30 issue-lock-reason: 'resolved' - process-only: 'issues' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1b4733cbe..47fca53d0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -102,23 +102,28 @@ appropriate labels will be applied for categorization. [getting started](https://docs.netbox.dev/en/stable/development/getting-started/) documentation for tips on setting up your development environment. -* Be sure to open an issue **before** starting work on a pull request, and -discuss your idea with the NetBox maintainers before beginning work. This will -help prevent wasting time on something that might we might not be able to -implement. When suggesting a new feature, also make sure it won't conflict with -any work that's already in progress. +* Be sure to open an issue and wait for it to be assigned to you **before** +starting work on a pull request, and discuss your idea with the NetBox +maintainers before beginning work. This will help prevent wasting time on +proposed changes that we might not be able to accept. When suggesting a new +feature, also make sure it won't conflict with any work that's already in +progress. * Once you've opened or identified an issue you'd like to work on, ask that it -be assigned to you so that others are aware it's being worked on. A maintainer -will then mark the issue as "accepted." +be assigned to you so that others are aware it's being worked on. If it meets +the acceptance criteria, a maintainer will then mark the issue as "accepted" +and assign it to you. (Note that GitHub requires that a user first comment on +an issue before it can be assigned to that user.) -* Any pull request which does _not_ relate to an **accepted** issue will be closed. +* Any pull request which does not relate to an **assigned** issue will be +closed. * All new functionality must include relevant tests where applicable. * When submitting a pull request, please be sure to work off of the `develop` branch, rather than `master`. The `develop` branch is used for ongoing -development, while `master` is used for tagging stable releases. +development, while `master` is used for tagging stable releases. (If you're +developing for the next minor release, use `feature` instead.) * In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts @@ -136,8 +141,10 @@ these checks): Only comment on an issue if you are sharing a relevant idea or constructive feedback. **Do not** comment on an issue just to show your support (give the -top post a :+1: instead) or ask for an ETA. These comments will be deleted to -reduce noise in the discussion. +top post a :+1: instead) or to ask for an update. Doing so generates +unnecessary noise in the discussion, and is especially annoying for people who +have subscribed to updates for the issue. Any comments without substance +relevant to the discussion will be deleted. ## Issue Lifecycle diff --git a/README.md b/README.md index 60f007946..654b290ee 100644 --- a/README.md +++ b/README.md @@ -2,14 +2,24 @@ NetBox logo +NetBox is the leading solution for modeling and documenting modern networks. By +combining the traditional disciplines of IP address management (IPAM) and +datacenter infrastructure management (DCIM) with powerful APIs and extensions, +NetBox provides the ideal "source of truth" to power network automation. +Available as open source software under the Apache 2.0 license, NetBox is +employed by thousands of organizations around the world. + ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) -NetBox is an infrastructure resource modeling (IRM) tool designed to empower -network automation, used by thousands of organizations around the world. -Initially conceived by the network engineering team at -[DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically -to address the needs of network and infrastructure engineers. It is intended to -function as a domain-specific source of truth for network operations. +[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits) +[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues) +[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls) +[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors) +
Stats via [Repography](https://repography.com) + +## About NetBox + +![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI") Myriad infrastructure components can be modeled in NetBox, including: @@ -21,6 +31,7 @@ Myriad infrastructure components can be modeled in NetBox, including: * Virtual machines and clusters * IP prefixes, ranges, and addresses * VRFs and route targets +* L2VPN and overlays * FHRP groups (VRRP, HSRP, etc.) * AS numbers * VLANs and scoped VLAN groups @@ -45,14 +56,16 @@ customized and extended through the use of: NetBox also features a complete REST API as well as a GraphQL API for easily integrating with other tools and systems. +The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). +A public demo instance is available at [demo.netbox.dev](https://demo.netbox.dev). + NetBox runs as a web application atop the [Django](https://www.djangoproject.com/) Python framework with a [PostgreSQL](https://www.postgresql.org/) database. For a -complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox). - -The complete documentation for NetBox can be found at [docs.netbox.dev](https://docs.netbox.dev/). A public demo instance is available at https://demo.netbox.dev. +complete list of requirements, see `requirements.txt`. The code is available +[on GitHub](https://github.com/netbox-community/netbox).
-

Thank you to our sponsors!

+

Thank you to our sponsors!

[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)            @@ -90,8 +103,6 @@ our [contributing guide](CONTRIBUTING.md) prior to beginning any work. ### Screenshots -![Screenshot of main page (light mode)](docs/media/screenshots/home-light.png "Main page (light mode)") - ![Screenshot of main page (dark mode)](docs/media/screenshots/home-dark.png "Main page (dark mode)") ![Screenshot of rack elevation](docs/media/screenshots/rack.png "Rack elevation") diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 230b003c6..e5d5a1ef5 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -129,6 +129,19 @@ The Script object provides a set of convenient functions for recording messages Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. +## Change Logging + +To generate the correct change log data when editing an existing object, a snapshot of the object must be taken before making any changes to the object. + +```python +if obj.pk and hasattr(obj, 'snapshot'): + obj.snapshot() + +obj.property = "New Value" +obj.full_clean() +obj.save() +``` + ## Variable Reference ### Default Options diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 38d521de6..7afd74608 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -35,6 +35,8 @@ base_requirements.txt contrib docs mkdocs.yml NOTICE requ CHANGELOG.md CONTRIBUTING.md LICENSE.txt netbox README.md scripts ``` +### 2. Create a New Branch + The NetBox project utilizes three persistent git branches to track work: * `master` - Serves as a snapshot of the current stable release @@ -46,7 +48,21 @@ Typically, you'll base pull requests off of the `develop` branch, or off of `fea !!! warning **Never** merge pull requests into the `master` branch: This branch only ever merges pull requests from the `develop` branch, to effect a new release. -### 2. Enable Pre-Commit Hooks +To create a new branch, first ensure that you've checked out the desired base branch, then run: + +```no-highlight +git checkout -B $branchname +``` + +When naming a new git branch, contributors are strongly encouraged to use the relevant issue number followed by a very brief description of the work: + +```no-highlight +$issue-$description +``` + +The description should be just two or three words to imply the focus of the work being performed. For example, bug #1234 to fix a TypeError exception when creating a device might be named `1234-device-typerror`. This ensures that branches are always follow some logical ordering (e.g. when running `git branch -a`) and helps other developers quickly identify the purpose of each. + +### 3. 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`: @@ -54,8 +70,14 @@ NetBox ships with a [git pre-commit hook](https://githooks.com/) script that aut cd .git/hooks/ ln -s ../../scripts/git-hooks/pre-commit ``` +For the pre-commit hooks to work, you will also need to install the pycodestyle package: -### 3. Create a Python Virtual Environment +```no-highlight +python -m pip install pycodestyle +``` +...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md) + +### 4. Create a Python Virtual Environment 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. @@ -79,7 +101,7 @@ source ~/.venv/netbox/bin/activate Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment. -### 4. Install Required Packages +### 5. Install Required Packages With the virtual environment activated, install the project's required Python packages using the `pip` module. Required packages are defined in `requirements.txt`. Each line in this file specifies the name and specific version of a required package. @@ -87,7 +109,7 @@ With the virtual environment activated, install the project's required Python pa python -m pip install -r requirements.txt ``` -### 5. Configure NetBox +### 6. Configure NetBox Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: @@ -98,7 +120,7 @@ Within the `netbox/netbox/` directory, copy `configuration_example.py` to `confi * `DEBUG`: Set to `True` * `DEVELOPER`: Set to `True` (this enables the creation of new database migrations) -### 6. Start the Development Server +### 7. Start the Development Server Django provides a lightweight, auto-updating [HTTP/WSGI server](https://docs.djangoproject.com/en/stable/ref/django-admin/#runserver) for development use. It is started with the `runserver` management command: @@ -118,6 +140,10 @@ This ensures that your development environment is now complete and operational. !!! tip "IDE Integration" Some IDEs, such as the highly-recommended [PyCharm](https://www.jetbrains.com/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. +## UI Development + +For UI development you will need to review the [Web UI Development Guide](web-ui.md) + ## Populating Demo Data Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at .) diff --git a/docs/development/git-cheat-sheet.md b/docs/development/git-cheat-sheet.md new file mode 100644 index 000000000..35b8e90b5 --- /dev/null +++ b/docs/development/git-cheat-sheet.md @@ -0,0 +1,388 @@ +# git Cheat Sheet + +This cheat sheet serves as a convenient reference for NetBox contributors who already somewhat familiar with using git. For a general introduction to the tooling and workflows involved, please see GitHub's guide [Getting started with git](https://docs.github.com/en/get-started/getting-started-with-git/setting-your-username-in-git). + +## Common Operations + +### Clone a Repo + +This copies a remote git repository (e.g. from GitHub) to your local workstation. It will create a new directory bearing the repo's name in the current path. + +``` title="Command" +git clone https://github.com/$org-name/$repo-name +``` + +``` title="Example" +$ git clone https://github.com/netbox-community/netbox +Cloning into 'netbox'... +remote: Enumerating objects: 95112, done. +remote: Counting objects: 100% (682/682), done. +remote: Compressing objects: 100% (246/246), done. +remote: Total 95112 (delta 448), reused 637 (delta 436), pack-reused 94430 +Receiving objects: 100% (95112/95112), 60.40 MiB | 45.82 MiB/s, done. +Resolving deltas: 100% (74979/74979), done. +``` + +### Pull New Commits + +To update your local branch with any recent upstream commits, run `git pull`. + +``` title="Command" +git pull +``` + +``` title="Example" +$ git pull +remote: Enumerating objects: 1, done. +remote: Counting objects: 100% (1/1), done. +remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 +Unpacking objects: 100% (1/1), done. +From https://github.com/netbox-community/netbox + 28bc76695..e0741cc9a develop -> origin/develop +Updating 28bc76695..e0741cc9a +Fast-forward + docs/release-notes/version-3.3.md | 1 + + netbox/netbox/settings.py | 1 + + 2 files changed, 2 insertions(+) +``` + +### List Branches + +`git branch` lists all local branches. Appending `-a` to this command will list both local (green) and remote (red) branches. + +``` title="Command" +git branch -a +``` + +``` title="Example" +$ git branch -a +* develop + remotes/origin/10170-changelog + remotes/origin/HEAD -> origin/develop + remotes/origin/develop + remotes/origin/feature + remotes/origin/master +``` + +### Switch Branches + +To switch to a different branch, use the `checkout` command. + +``` title="Command" +git checkout $branchname +``` + +``` title="Example" +$ git checkout feature +Branch 'feature' set up to track remote branch 'feature' from 'origin'. +Switched to a new branch 'feature' +``` + +### Create a New Branch + +Use the `-b` argument with `checkout` to create a new _local_ branch from the current branch. + +``` title="Command" +git checkout -b $newbranch +``` + +``` title="Example" +$ git checkout -b 123-fix-foo +Switched to a new branch '123-fix-foo' +``` + +### Rename a Branch + +To rename the current branch, use the `git branch` command with the `-m` argument (for "modify"). + +``` title="Command" +git branch -m $newname +``` + +``` title="Example" +$ git branch -m jstretch-testing +$ git branch + develop + feature +* jstretch-testing +``` + +### Merge a Branch + +To merge one branch into another, use the `git merge` command. Start by checking out the _destination_ branch, and merge the _source_ branch into it. + +``` title="Command" +git merge $sourcebranch +``` + +``` title="Example" +$ git checkout testing +Switched to branch 'testing' +Your branch is up to date with 'origin/testing'. +$ git merge branch2 +Updating 9a12b5b5f..8ee42390b +Fast-forward + newfile.py | 0 + 1 file changed, 0 insertions(+), 0 deletions(-) + create mode 100644 newfile.py +``` + +!!! warning "Avoid Merging Remote Branches" + You generally want to avoid merging branches that exist on the remote (upstream) repository, such as `develop` and `feature`: Merges into these branches should be done via a pull request on GitHub. Only merge branches when it is necessary to consolidate work you've done locally. + +### Show Pending Changes + +After making changes to files in the repo, `git status` will display a summary of created, modified, and deleted files. + +``` title="Command" +git status +``` + +``` title="Example" +$ git status +On branch 123-fix-foo +Changes not staged for commit: + (use "git add ..." to update what will be committed) + (use "git checkout -- ..." to discard changes in working directory) + + modified: README.md + +Untracked files: + (use "git add ..." to include in what will be committed) + + foo.py + +no changes added to commit (use "git add" and/or "git commit -a") +``` + +### Stage Changed Files + +Before creating a new commit, modified files must be staged. This is typically done with the `git add` command. You can specify a particular path, or just append `-A` to automatically staged _all_ changed files within the current directory. Run `git status` again to verify what files have been staged. + +``` title="Command" +git add -A +``` + +``` title="Example" +$ git add -A +$ git status +On branch 123-fix-foo +Changes to be committed: + (use "git reset HEAD ..." to unstage) + + modified: README.md + new file: foo.py + +``` + +### Review Staged Files + +It's a good idea to thoroughly review all staged changes immediately prior to creating a new commit. This can be done using the `git diff` command. Appending the `--staged` argument will show staged changes; omitting it will show changes that have not yet been staged. + +``` title="Command" +git diff --staged +``` + +``` title="Example" +$ git diff --staged +diff --git a/README.md b/README.md +index 93e125079..4344fb514 100644 +--- a/README.md ++++ b/README.md +@@ -1,3 +1,8 @@ ++ ++Added some lines here ++and here ++and here too ++ +
+ NetBox logo +
+diff --git a/foo.py b/foo.py +new file mode 100644 +index 000000000..e69de29bb +``` + +### Create a New Commit + +The `git commit` command records your changes to the current branch. Specify a commit message with the `-m` argument. (If omitted, a file editor will be opened to provide a message. + +``` title="Command" +git commit -m "Fixes #123: Fixed the thing that was broken" +``` + +``` title="Example" +$ git commit -m "Fixes #123: Fixed the thing that was broken" +[123-fix-foo 9a12b5b5f] Fixes #123: Fixed the thing that was broken + 2 files changed, 5 insertions(+) + create mode 100644 foo.py +``` + +!!! tip "Automatically Closing Issues" + GitHub will [automatically close](https://github.blog/2013-01-22-closing-issues-via-commit-messages/) any issues referenced in a commit message by `Fixes:` or `Closes:` when the commit is merged into the repository's default branch. Contributors are strongly encouraged to follow this convention when forming commit messages. (Use "Closes" for feature requests and "Fixes" for bugs.) + +### Push a Commit Upstream + +Once you've made a commit locally, it needs to be pushed upstream to the _remote_ repository (typically called "origin"). This is done with the `git push` command. If this is a new branch that doesn't yet exist on the remote repository, you'll need to set the upstream for it when pushing. + +``` title="Command" +git push -u origin $branchname +``` + +``` title="Example" +$ git push -u origin testing +Counting objects: 3, done. +Delta compression using up to 16 threads. +Compressing objects: 100% (3/3), done. +Writing objects: 100% (3/3), 377 bytes | 377.00 KiB/s, done. +Total 3 (delta 2), reused 0 (delta 0) +remote: Resolving deltas: 100% (2/2), completed with 2 local objects. +remote: +remote: Create a pull request for 'testing' on GitHub by visiting: +remote: https://github.com/netbox-community/netbox/pull/new/testing +remote: +To https://github.com/netbox-community/netbox + * [new branch] testing -> testing +Branch 'testing' set up to track remote branch 'testing' from 'origin'. +``` + +!!! tip + You can apply the following git configuration to automatically set the upstream for all new branches. This obviates the need to specify `-u origin`. + + ``` + git config --global push.default current + ``` + +## The GitHub CLI Client + +GitHub provides a [free CLI client](https://cli.github.com/) to simplify many aspects of interacting with GitHub repositories. Note that this utility is separate from `git`, and must be [installed separately](https://github.com/cli/cli#installation). + +This guide provides some examples of common operations, but be sure to check out the [GitHub CLI manual](https://cli.github.com/manual/) for a complete accounting of available commands. + +### List Open Pull Requests + +``` title="Command" +gh pr list +``` + +``` title="Example" +$ gh pr list + +Showing 3 of 3 open pull requests in netbox-community/netbox + +#10223 #7503 API Bulk-Create of Devices does not check Rack-Space 7503-bulkdevice about 17 hours ago +#9716 Closes #9599: Add cursor pagination mode lyuyangh:cursor-pagination about 1 month ago +#9498 Adds replication and adoption for module import sleepinggenius2:issue_9361 about 2 months ago +``` + +### Check Out a PR + +This command will automatically check out the remote branch associated with an open pull request. + +``` title="Command" +gh pr checkout $number +``` + +``` title="Example" +$ gh pr checkout 10223 +Branch '7503-bulkdevice' set up to track remote branch '7503-bulkdevice' from 'origin'. +Switched to a new branch '7503-bulkdevice' +``` + +## Fixing Mistakes + +### Modify the Previous Commit + +Sometimes you'll find that you've overlooked a necessary change and need to commit again. If you haven't pushed your most recent commit and just need to make a small tweak or two, you can _amend_ your most recent commit instead of creating a new one. + +First, stage the desired files with `git add` and verify the changes, the issue the `git commit` command with the `--amend` argument. You can also append the `--no-edit` argument if you would like to keep the previous commit message. + +``` title="Command" +git commit --amend --no-edit +``` + +``` title="Example" +$ git add -A +$ git diff --staged +$ git commit --amend --no-edit +[testing 239b16921] Added a new file + Date: Fri Aug 26 16:30:05 2022 -0400 + 2 files changed, 1 insertion(+) + create mode 100644 newfile.py +``` + +!!! danger "Don't Amend After Pushing" + Never amend a commit you've already pushed upstream unless you're **certain** no one else is working on the same branch. Force-pushing will overwrite the change history, which will break any commits from other contributors. When in doubt, create a new commit instead. + +### Undo the Last Commit + +The `git reset` command can be used to undo the most recent commit. (`HEAD~` is equivalent to `HEAD~1` and references the commit prior to the current HEAD.) After making and staging your changes, commit using `-c ORIG_HEAD` to replace the erroneous commit. + +``` title="Command" +git reset HEAD~ +``` + +``` title="Example" +$ git add -A +$ git commit -m "Erroneous commit" +[testing 09ce06736] Erroneous commit + Date: Mon Aug 29 15:20:04 2022 -0400 + 1 file changed, 1 insertion(+) + create mode 100644 BADCHANGE +$ git reset HEAD~ +$ rm BADFILE +$ git add -A +$ git commit -m "Fixed commit" +[testing c585709f3] Fixed commit + Date: Mon Aug 29 15:22:38 2022 -0400 + 1 file changed, 65 insertions(+), 20 deletions(-) +``` + +!!! danger "Don't Reset After Pushing" + Resetting only works until you've pushed your local changes upstream. If you've already pushed upstream, use `git revert` instead. This will create a _new_ commit that reverts the erroneous one, but ensures that the git history remains intact. + +### Rebase from Upstream + +If a change has been pushed to the upstream branch since you most recently pulled it, attempting to push a new local commit will fail: + +``` +$ git push +To https://github.com/netbox-community/netbox.git + ! [rejected] develop -> develop (fetch first) +error: failed to push some refs to 'https://github.com/netbox-community/netbox.git' +hint: Updates were rejected because the remote contains work that you do +hint: not have locally. This is usually caused by another repository pushing +hint: to the same ref. You may want to first integrate the remote changes +hint: (e.g., 'git pull ...') before pushing again. +hint: See the 'Note about fast-forwards' in 'git push --help' for details. +``` + +To resolve this, first fetch the upstream branch to update your local copy, and then [rebase](https://git-scm.com/book/en/v2/Git-Branching-Rebasing) your local branch to include the new changes. Once the rebase has completed, you can push your local commits upstream. + +``` title="Commands" +git fetch +git rebase origin/$branchname +``` + +``` title="Example" +$ git fetch +remote: Enumerating objects: 1, done. +remote: Counting objects: 100% (1/1), done. +remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 +Unpacking objects: 100% (1/1), done. +From https://github.com/netbox-community/netbox + 815b2d8a2..8c35ebbb7 develop -> origin/develop +$ git rebase origin/develop +First, rewinding head to replay your work on top of it... +Applying: Further tweaks to the PR template +Applying: Changelog for #10176, #10217 +$ git push +Counting objects: 9, done. +Delta compression using up to 16 threads. +Compressing objects: 100% (9/9), done. +Writing objects: 100% (9/9), 1.02 KiB | 1.02 MiB/s, done. +Total 9 (delta 6), reused 0 (delta 0) +remote: Resolving deltas: 100% (6/6), completed with 5 local objects. +To https://github.com/netbox-community/netbox.git + 8c35ebbb7..ada745324 develop -> develop +``` diff --git a/docs/index.md b/docs/index.md index c233dedb7..d61465443 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,8 @@ NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, NetBox provides the ideal "source of truth" to power network automation. Read on to discover why thousands of organizations worldwide put NetBox at the heart of their infrastructure. +[![NetBox UI](./media/screenshots/netbox-ui.png)](./media/screenshots/netbox-ui.png) + ## :material-server-network: Built for Networks Unlike general-purpose CMDBs, NetBox has curated a data model which caters specifically to the needs of network engineers and operators. It delivers a wide assortment of object types carefully crafted to best serve the needs of infrastructure design and documentation. These cover all facets of network technology, from IP address managements to cabling to overlays and more: diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index eeb5e6f20..f42e28deb 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. !!! warning "Python 3.8 or later required" - NetBox v3.2 requires Python 3.8, 3.9, or 3.10. + NetBox requires Python 3.8, 3.9, or 3.10. === "Ubuntu" diff --git a/docs/installation/index.md b/docs/installation/index.md index 905add7ab..8b588fccd 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -2,6 +2,8 @@ The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. + + The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index deeec883a..802c13e49 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -1,10 +1,19 @@ # Upgrading to a New NetBox Release -## Review the Release Notes +Upgrading NetBox to a new version is pretty simple, however users are cautioned to always review the release notes and save a backup of their current deployment prior to beginning an upgrade. + +NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change). + +[![Upgrade paths](../media/installation/upgrade_paths.png)](../media/installation/upgrade_paths.png) + +!!! warning "Perform a Backup" + Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process. + +## 1. Review the Release Notes Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the release in which the change went into effect. -## Update Dependencies to Required Versions +## 2. Update Dependencies to Required Versions NetBox v3.0 and later require the following: @@ -14,7 +23,7 @@ NetBox v3.0 and later require the following: | PostgreSQL | 10 | | Redis | 4.0 | -## Install the Latest Release +## 3. Install the Latest Release As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. @@ -87,7 +96,7 @@ sudo git pull origin master sudo git checkout v2.11.11 -## Run the Upgrade Script +## 4. Run the Upgrade Script Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: @@ -118,7 +127,7 @@ This script performs the following actions: been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are intentionally modifying the database schema. -## Restart the NetBox Services +## 5. Restart the NetBox Services !!! warning If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference. @@ -129,7 +138,7 @@ Finally, restart the gunicorn and RQ services: sudo systemctl restart netbox netbox-rq ``` -## Verify Housekeeping Scheduling +## 6. Verify Housekeeping Scheduling If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.) diff --git a/docs/media/installation/upgrade_paths.png b/docs/media/installation/upgrade_paths.png new file mode 100644 index 000000000..494744b58 Binary files /dev/null and b/docs/media/installation/upgrade_paths.png differ diff --git a/docs/media/screenshots/home-dark.png b/docs/media/screenshots/home-dark.png index 796637ac5..f6290fa55 100644 Binary files a/docs/media/screenshots/home-dark.png and b/docs/media/screenshots/home-dark.png differ diff --git a/docs/media/screenshots/home-light.png b/docs/media/screenshots/home-light.png deleted file mode 100644 index 78d54a7d2..000000000 Binary files a/docs/media/screenshots/home-light.png and /dev/null differ diff --git a/docs/media/screenshots/netbox-ui.png b/docs/media/screenshots/netbox-ui.png new file mode 100644 index 000000000..fb692558f Binary files /dev/null and b/docs/media/screenshots/netbox-ui.png differ diff --git a/docs/plugins/development/filtersets.md b/docs/plugins/development/filtersets.md index 318ad5b4e..d803ce2f4 100644 --- a/docs/plugins/development/filtersets.md +++ b/docs/plugins/development/filtersets.md @@ -34,12 +34,12 @@ To utilize a filter set in a subclass of one of NetBox's generic views (such as ```python # views.py from netbox.views.generic import ObjectListView -from .filtersets import MyModelFitlerSet +from .filtersets import MyModelFilterSet from .models import MyModel class MyModelListView(ObjectListView): queryset = MyModel.objects.all() - filterset = MyModelFitlerSet + filterset = MyModelFilterSet ``` To enable a filter set on a REST API endpoint, set the `filterset_class` attribute on the API view: diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 7ed635bc2..71f5605f9 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,24 +1,116 @@ # NetBox v3.3 -## v3.3.1 (FUTURE) +## v3.3.5 (FUTURE) + +--- + +## v3.3.4 (2022-09-16) + +### Bug Fixes + +* [#9497](https://github.com/netbox-community/netbox/issues/9497) - Adjust non-racked device filter on site and location detailed view +* [#10383](https://github.com/netbox-community/netbox/issues/10383) - Fix assignment of component templates to module types via web UI +* [#10387](https://github.com/netbox-community/netbox/issues/10387) - Fix `MultiValueDictKeyError` exception when editing a device interface + +--- + +## v3.3.3 (2022-09-15) + +### Enhancements + +* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` +* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types +* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI +* [#10359](https://github.com/netbox-community/netbox/issues/10359) - Add region and site group columns to the devices table + +### Bug Fixes + +* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10247](https://github.com/netbox-community/netbox/issues/10247) - Allow changing the pre-populated device/VM when creating new components +* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables +* [#10258](https://github.com/netbox-community/netbox/issues/10258) - Enable the use of reports & scripts packaged in submodules +* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed +* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services +* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments +* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field +* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links +* [#10305](https://github.com/netbox-community/netbox/issues/10305) - Fix Virtual Chassis master field cannot be null according to the API +* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection +* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import +* [#10337](https://github.com/netbox-community/netbox/issues/10337) - Display SSO links when local authentication fails +* [#10353](https://github.com/netbox-community/netbox/issues/10353) - Table action buttons should reserve return URL parameters +* [#10362](https://github.com/netbox-community/netbox/issues/10362) - Correct display of custom fields when editing an L2VPN termination + +--- + +## v3.3.2 (2022-09-02) + +### Enhancements + +* [#9477](https://github.com/netbox-community/netbox/issues/9477) - Enable clearing applied table column ordering +* [#10034](https://github.com/netbox-community/netbox/issues/10034) - Add L2VPN column to interface and VLAN tables +* [#10043](https://github.com/netbox-community/netbox/issues/10043) - Add support for `limit` query parameter to available VLANs API endpoint +* [#10060](https://github.com/netbox-community/netbox/issues/10060) - Add journal entries to global search +* [#10195](https://github.com/netbox-community/netbox/issues/10195) - Enable filtering of device components by rack +* [#10233](https://github.com/netbox-community/netbox/issues/10233) - Enable sorting rack elevations by facility ID + +### Bug Fixes + +* [#9328](https://github.com/netbox-community/netbox/issues/9328) - Hide available IPs when non-default ordering is applied +* [#9481](https://github.com/netbox-community/netbox/issues/9481) - Update child device location when parent location changes +* [#9832](https://github.com/netbox-community/netbox/issues/9832) - Improve error message when validating rack reservation units +* [#9895](https://github.com/netbox-community/netbox/issues/9895) - Various corrections to OpenAPI spec +* [#9962](https://github.com/netbox-community/netbox/issues/9962) - SSO login should respect `next` URL query parameter +* [#9963](https://github.com/netbox-community/netbox/issues/9963) - Fix support for custom `CSRF_COOKIE_NAME` value +* [#10155](https://github.com/netbox-community/netbox/issues/10155) - Fix rear port display when editing front port template for module type +* [#10156](https://github.com/netbox-community/netbox/issues/10156) - Avoid forcing SVG image links to open in a new window +* [#10161](https://github.com/netbox-community/netbox/issues/10161) - Restore "set null" option for custom fields during bulk edit +* [#10176](https://github.com/netbox-community/netbox/issues/10176) - Correct utilization display for empty racks +* [#10177](https://github.com/netbox-community/netbox/issues/10177) - Correct display of custom fields when editing VM interfaces +* [#10178](https://github.com/netbox-community/netbox/issues/10178) - Display manufacturer name alongside device type under device view +* [#10181](https://github.com/netbox-community/netbox/issues/10181) - Restore MultiPartParser (regression from #10031) +* [#10184](https://github.com/netbox-community/netbox/issues/10184) - Fix vertical alignment when displaying object attributes with buttons +* [#10208](https://github.com/netbox-community/netbox/issues/10208) - Fix permissions evaluation for interface actions dropdown menu +* [#10217](https://github.com/netbox-community/netbox/issues/10217) - Handle exception when trace splits to multiple rear ports +* [#10220](https://github.com/netbox-community/netbox/issues/10220) - Validate IP version when assigning primary IPs to a virtual machine +* [#10231](https://github.com/netbox-community/netbox/issues/10231) - Correct API schema definition for several serializer fields + +--- + +## v3.3.1 (2022-08-25) ### Enhancements * [#6454](https://github.com/netbox-community/netbox/issues/6454) - Include contextual help when creating first objects in UI +* [#9935](https://github.com/netbox-community/netbox/issues/9935) - Add 802.11ay and "other" wireless interface types +* [#10031](https://github.com/netbox-community/netbox/issues/10031) - Enforce `application/json` content type for REST API requests +* [#10033](https://github.com/netbox-community/netbox/issues/10033) - Disable "add termination" button for point-to-point L2VPNs with two terminations +* [#10037](https://github.com/netbox-community/netbox/issues/10037) - Add "child interface" option to actions dropdown in interfaces list +* [#10038](https://github.com/netbox-community/netbox/issues/10038) - Add "L2VPN termination" option to actions dropdown in interfaces list +* [#10039](https://github.com/netbox-community/netbox/issues/10039) - Add "assign FHRP group" option to actions dropdown in interfaces list * [#10061](https://github.com/netbox-community/netbox/issues/10061) - Replicate type when cloning L2VPN instances * [#10066](https://github.com/netbox-community/netbox/issues/10066) - Use fixed column widths for custom field values in UI +* [#10133](https://github.com/netbox-community/netbox/issues/10133) - Enable nullifying device location during bulk edit ### Bug Fixes +* [#9663](https://github.com/netbox-community/netbox/issues/9663) - Omit available IP annotations when filtering prefix child IPs list * [#10040](https://github.com/netbox-community/netbox/issues/10040) - Fix exception when ordering prefixes by flat representation * [#10053](https://github.com/netbox-community/netbox/issues/10053) - Custom fields header should not be displayed when editing circuit terminations with no custom fields * [#10055](https://github.com/netbox-community/netbox/issues/10055) - Fix extraneous NAT indicator by device primary IP * [#10057](https://github.com/netbox-community/netbox/issues/10057) - Fix AttributeError exception when global search results include rack reservations * [#10059](https://github.com/netbox-community/netbox/issues/10059) - Add identifier column to L2VPN table +* [#10070](https://github.com/netbox-community/netbox/issues/10070) - Add unique constraint for L2VPN slug +* [#10087](https://github.com/netbox-community/netbox/issues/10087) - Correct display of far end in console/power/interface connections tables * [#10089](https://github.com/netbox-community/netbox/issues/10089) - `linkify` template filter should escape object representation +* [#10094](https://github.com/netbox-community/netbox/issues/10094) - Fix 404 when using "create and add another" to add contact assignments * [#10108](https://github.com/netbox-community/netbox/issues/10108) - Linkify inside NAT IPs for primary device IPs in UI * [#10109](https://github.com/netbox-community/netbox/issues/10109) - Fix available prefixes calculation for container prefixes in the global table -* [#10111](https://github.com/netbox-community/netbox/issues/10111) - Wrap search QS to catch ValueError on identifier field +* [#10111](https://github.com/netbox-community/netbox/issues/10111) - Fix ValueError exception when searching for L2VPN objects +* [#10118](https://github.com/netbox-community/netbox/issues/10118) - Fix display of connected LLDP neighbors for devices +* [#10134](https://github.com/netbox-community/netbox/issues/10134) - Custom fields data serializer should return a 400 response for invalid data +* [#10135](https://github.com/netbox-community/netbox/issues/10135) - Fix SSO support for SAML2 IDPs +* [#10147](https://github.com/netbox-community/netbox/issues/10147) - Permit the creation of 0U device types via REST API --- diff --git a/mkdocs.yml b/mkdocs.yml index eef000481..530c6d52e 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -249,6 +249,7 @@ nav: - User Preferences: 'development/user-preferences.md' - Web UI: 'development/web-ui.md' - Release Checklist: 'development/release-checklist.md' + - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' - Version 3.3: 'release-notes/version-3.3.md' diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index abcfa8a00..2646de3c2 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), ) Circuit.objects.bulk_create(circuits) @@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -397,11 +399,19 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_provider_network(self): provider_networks = ProviderNetwork.objects.all()[:2] params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests): diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 1be8bb9dc..f5e06e155 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -316,6 +316,7 @@ class NestedModuleSerializer(WritableNestedSerializer): class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.ConsoleServerPort @@ -325,6 +326,7 @@ class NestedConsoleServerPortSerializer(WritableNestedSerializer): class NestedConsolePortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer(read_only=True) + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.ConsolePort @@ -334,6 +336,7 @@ class NestedConsolePortSerializer(WritableNestedSerializer): class NestedPowerOutletSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer(read_only=True) + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.PowerOutlet @@ -343,6 +346,7 @@ class NestedPowerOutletSerializer(WritableNestedSerializer): class NestedPowerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer(read_only=True) + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.PowerPort @@ -352,6 +356,7 @@ class NestedPowerPortSerializer(WritableNestedSerializer): class NestedInterfaceSerializer(WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.Interface @@ -361,6 +366,7 @@ class NestedInterfaceSerializer(WritableNestedSerializer): class NestedRearPortSerializer(WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail') + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.RearPort @@ -370,6 +376,7 @@ class NestedRearPortSerializer(WritableNestedSerializer): class NestedFrontPortSerializer(WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail') + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.FrontPort @@ -454,6 +461,7 @@ class NestedPowerPanelSerializer(WritableNestedSerializer): class NestedPowerFeedSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail') + _occupied = serializers.BooleanField(required=False, read_only=True) class Meta: model = models.PowerFeed diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 249a3f167..897ee4ca3 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -310,7 +310,7 @@ class DeviceTypeSerializer(NetBoxModelSerializer): max_digits=4, decimal_places=1, label='Position (U)', - min_value=decimal.Decimal(0.5), + min_value=0, default=1.0 ) subdevice_role = ChoiceField(choices=SubdeviceRoleChoices, allow_blank=True, required=False) @@ -579,7 +579,7 @@ class InventoryItemTemplateSerializer(ValidatedModelSerializer): 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', ] - @swagger_serializer_method(serializer_or_field=serializers.DictField) + @swagger_serializer_method(serializer_or_field=serializers.JSONField) def get_component(self, obj): if obj.component is None: return None @@ -693,13 +693,13 @@ class DeviceWithConfigContextSerializer(DeviceSerializer): 'local_context_data', 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.DictField) + @swagger_serializer_method(serializer_or_field=serializers.JSONField) def get_config_context(self, obj): return obj.get_config_context() class DeviceNAPALMSerializer(serializers.Serializer): - method = serializers.DictField() + method = serializers.JSONField() # @@ -975,7 +975,7 @@ class InventoryItemSerializer(NetBoxModelSerializer): 'custom_fields', 'created', 'last_updated', '_depth', ] - @swagger_serializer_method(serializer_or_field=serializers.DictField) + @swagger_serializer_method(serializer_or_field=serializers.JSONField) def get_component(self, obj): if obj.component is None: return None @@ -1046,7 +1046,7 @@ class CableTerminationSerializer(NetBoxModelSerializer): 'id', 'url', 'display', 'cable', 'cable_end', 'termination_type', 'termination_id', 'termination' ] - @swagger_serializer_method(serializer_or_field=serializers.DictField) + @swagger_serializer_method(serializer_or_field=serializers.JSONField) def get_termination(self, obj): serializer = get_serializer_for_model(obj.termination, prefix=NESTED_SERIALIZER_PREFIX) context = {'request': self.context['request']} @@ -1076,7 +1076,7 @@ class CablePathSerializer(serializers.ModelSerializer): class VirtualChassisSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualchassis-detail') - master = NestedDeviceSerializer(required=False) + master = NestedDeviceSerializer(required=False, allow_null=True, default=None) member_count = serializers.IntegerField(read_only=True) class Meta: diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 79049384a..7d35a40f9 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -790,7 +790,9 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_80211AC = 'ieee802.11ac' TYPE_80211AD = 'ieee802.11ad' TYPE_80211AX = 'ieee802.11ax' + TYPE_80211AY = 'ieee802.11ay' TYPE_802151 = 'ieee802.15.1' + TYPE_OTHER_WIRELESS = 'other-wireless' # Cellular TYPE_GSM = 'gsm' @@ -918,7 +920,9 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_80211AC, 'IEEE 802.11ac'), (TYPE_80211AD, 'IEEE 802.11ad'), (TYPE_80211AX, 'IEEE 802.11ax'), + (TYPE_80211AY, 'IEEE 802.11ay'), (TYPE_802151, 'IEEE 802.15.1 (Bluetooth)'), + (TYPE_OTHER_WIRELESS, 'Other (Wireless)'), ) ), ( @@ -1092,7 +1096,7 @@ class InterfacePoETypeChoices(ChoiceSet): (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), - (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + (PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'), ) ), ) diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 9e41ed113..80d7558c9 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -45,6 +45,9 @@ WIRELESS_IFACE_TYPES = [ InterfaceTypeChoices.TYPE_80211AC, InterfaceTypeChoices.TYPE_80211AD, InterfaceTypeChoices.TYPE_80211AX, + InterfaceTypeChoices.TYPE_80211AY, + InterfaceTypeChoices.TYPE_802151, + InterfaceTypeChoices.TYPE_OTHER_WIRELESS, ] NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 874d08ba5..0a4439173 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + has_front_image = django_filters.BooleanFilter( + label='Has a front image', + method='_has_front_image' + ) + has_rear_image = django_filters.BooleanFilter( + label='Has a rear image', + method='_has_rear_image' + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label='Has console ports', @@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): Q(comments__icontains=value) ) + def _has_front_image(self, queryset, name, value): + if value: + return queryset.exclude(front_image='') + else: + return queryset.filter(front_image='') + + def _has_rear_image(self, queryset, name, value): + if value: + return queryset.exclude(rear_image='') + else: + return queryset.filter(rear_image='') + def _console_ports(self, queryset, name, value): return queryset.exclude(consoleporttemplates__isnull=value) @@ -1084,6 +1104,17 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='slug', label='Location (slug)', ) + rack_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__rack', + queryset=Rack.objects.all(), + label='Rack (ID)', + ) + rack = django_filters.ModelMultipleChoiceFilter( + field_name='device__rack__name', + queryset=Rack.objects.all(), + to_field_name='name', + label='Rack (name)', + ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', @@ -1133,6 +1164,15 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) + occupied = django_filters.BooleanFilter( + method='filter_occupied' + ) + + def filter_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) class PathEndpointFilterSet(django_filters.FilterSet): diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 43b852928..f6bc27079 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -3,7 +3,7 @@ from django import forms from dcim.models import * from extras.forms import CustomFieldsMixin from extras.models import Tag -from utilities.forms import DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model +from utilities.forms import BootstrapMixin, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model from .object_create import ComponentCreateForm __all__ = ( @@ -24,7 +24,7 @@ __all__ = ( # Device components # -class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): +class DeviceBulkAddComponentForm(BootstrapMixin, CustomFieldsMixin, ComponentCreateForm): pk = forms.ModelMultipleChoiceField( queryset=Device.objects.all(), widget=forms.MultipleHiddenInput() @@ -37,6 +37,7 @@ class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm): queryset=Tag.objects.all(), required=False ) + replication_fields = ('name', 'label') class ConsolePortBulkCreateForm( @@ -44,7 +45,7 @@ class ConsolePortBulkCreateForm( DeviceBulkAddComponentForm ): model = ConsolePort - field_order = ('name_pattern', 'label_pattern', 'type', 'mark_connected', 'description', 'tags') + field_order = ('name', 'label', 'type', 'mark_connected', 'description', 'tags') class ConsoleServerPortBulkCreateForm( @@ -52,7 +53,7 @@ class ConsoleServerPortBulkCreateForm( DeviceBulkAddComponentForm ): model = ConsoleServerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'speed', 'description', 'tags') + field_order = ('name', 'label', 'type', 'speed', 'description', 'tags') class PowerPortBulkCreateForm( @@ -60,7 +61,7 @@ class PowerPortBulkCreateForm( DeviceBulkAddComponentForm ): model = PowerPort - field_order = ('name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') + field_order = ('name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'tags') class PowerOutletBulkCreateForm( @@ -68,7 +69,7 @@ class PowerOutletBulkCreateForm( DeviceBulkAddComponentForm ): model = PowerOutlet - field_order = ('name_pattern', 'label_pattern', 'type', 'feed_leg', 'description', 'tags') + field_order = ('name', 'label', 'type', 'feed_leg', 'description', 'tags') class InterfaceBulkCreateForm( @@ -79,7 +80,7 @@ class InterfaceBulkCreateForm( ): model = Interface field_order = ( - 'name_pattern', 'label_pattern', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', + 'name', 'label', 'type', 'enabled', 'speed', 'duplex', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mark_connected', 'description', 'tags', ) @@ -96,13 +97,13 @@ class RearPortBulkCreateForm( DeviceBulkAddComponentForm ): model = RearPort - field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags') + field_order = ('name', 'label', 'type', 'positions', 'mark_connected', 'description', 'tags') class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): model = ModuleBay - field_order = ('name_pattern', 'label_pattern', 'position_pattern', 'description', 'tags') - + field_order = ('name', 'label', 'position_pattern', 'description', 'tags') + replication_fields = ('name', 'label', 'position') position_pattern = ExpandableNameField( label='Position', required=False, @@ -112,7 +113,7 @@ class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm): class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): model = DeviceBay - field_order = ('name_pattern', 'label_pattern', 'description', 'tags') + field_order = ('name', 'label', 'description', 'tags') class InventoryItemBulkCreateForm( @@ -121,6 +122,6 @@ class InventoryItemBulkCreateForm( ): model = InventoryItem field_order = ( - 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', + 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description', 'tags', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8f765ae9b..396f7e59b 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -480,7 +480,7 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm): ('Hardware', ('manufacturer', 'device_type', 'airflow', 'serial')), ) nullable_fields = ( - 'tenant', 'platform', 'serial', 'airflow', + 'location', 'tenant', 'platform', 'serial', 'airflow', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 16ff6fee2..96b0d1319 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -87,6 +87,15 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): }, label=_('Location') ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), + required=False, + query_params={ + 'site_id': '$site_id', + 'location_id': '$location_id', + }, + label=_('Rack') + ) virtual_chassis_id = DynamicModelMultipleChoiceField( queryset=VirtualChassis.objects.all(), required=False, @@ -356,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', @@ -377,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=add_blank_choice(DeviceAirflowChoices), required=False ) + has_front_image = forms.NullBooleanField( + required=False, + label='Has a front image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + has_rear_image = forms.NullBooleanField( + required=False, + label='Has a rear image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', @@ -927,12 +951,37 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): # Device components # -class ConsolePortFilterForm(DeviceComponentFilterForm): +class CabledFilterForm(forms.Form): + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class PathEndpointFilterForm(CabledFilterForm): + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsolePort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), ) type = MultipleChoiceField( choices=ConsolePortTypeChoices, @@ -945,12 +994,13 @@ class ConsolePortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class ConsoleServerPortFilterForm(DeviceComponentFilterForm): +class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = ConsoleServerPort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), ) type = MultipleChoiceField( choices=ConsolePortTypeChoices, @@ -963,12 +1013,13 @@ class ConsoleServerPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerPortFilterForm(DeviceComponentFilterForm): +class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerPort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), ) type = MultipleChoiceField( choices=PowerPortTypeChoices, @@ -977,12 +1028,13 @@ class PowerPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class PowerOutletFilterForm(DeviceComponentFilterForm): +class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = PowerOutlet fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), ) type = MultipleChoiceField( choices=PowerOutletTypeChoices, @@ -991,7 +1043,7 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceFilterForm(DeviceComponentFilterForm): +class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -999,7 +1051,8 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ('Addressing', ('vrf_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1080,11 +1133,12 @@ class InterfaceFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortFilterForm(DeviceComponentFilterForm): +class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) model = FrontPort type = MultipleChoiceField( @@ -1097,12 +1151,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortFilterForm(DeviceComponentFilterForm): +class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) type = MultipleChoiceField( choices=PortTypeChoices, @@ -1119,7 +1174,7 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'position')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1132,7 +1187,7 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) tag = TagFilterField(model) @@ -1142,7 +1197,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index edf25cf2c..5728e7f2d 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -986,47 +986,85 @@ class VCMemberSelectForm(BootstrapMixin, forms.Form): # Device component templates # +class ComponentTemplateForm(BootstrapMixin, forms.ModelForm): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of DeviceType when editing an existing instance + if self.instance.pk: + self.fields['device_type'].disabled = True + + +class ModularComponentTemplateForm(ComponentTemplateForm): + device_type = DynamicModelChoiceField( + queryset=DeviceType.objects.all().all(), + required=False + ) + module_type = DynamicModelChoiceField( + queryset=ModuleType.objects.all(), + required=False + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of ModuleType when editing an existing instance + if self.instance.pk: + self.fields['module_type'].disabled = True + + +class ConsolePortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + ) -class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect, } -class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): +class ConsoleServerPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'description')), + ) + class Meta: model = ConsoleServerPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect, } -class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): +class PowerPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ( + 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', + )), + ) + class Meta: model = PowerPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): +class PowerOutletTemplateForm(ModularComponentTemplateForm): power_port = DynamicModelChoiceField( queryset=PowerPortTemplate.objects.all(), required=False, @@ -1035,43 +1073,56 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): } ) + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description')), + ) + class Meta: model = PowerOutletTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), 'feed_leg': StaticSelect(), } -class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): +class InterfaceTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description')), + ('PoE', ('poe_mode', 'poe_type')) + ) + class Meta: model = InterfaceTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description', 'poe_mode', 'poe_type', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), 'poe_mode': StaticSelect(), 'poe_type': StaticSelect(), } -class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): +class FrontPortTemplateForm(ModularComponentTemplateForm): rear_port = DynamicModelChoiceField( queryset=RearPortTemplate.objects.all(), required=False, query_params={ 'devicetype_id': '$device_type', + 'moduletype_id': '$module_type', } ) + fieldsets = ( + (None, ( + 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', + 'description', + )), + ) + class Meta: model = FrontPortTemplate fields = [ @@ -1079,48 +1130,50 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm): 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class RearPortTemplateForm(BootstrapMixin, forms.ModelForm): +class RearPortTemplateForm(ModularComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description')), + ) + class Meta: model = RearPortTemplate fields = [ 'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description', ] widgets = { - 'device_type': forms.HiddenInput(), - 'module_type': forms.HiddenInput(), 'type': StaticSelect(), } -class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm): +class ModuleBayTemplateForm(ComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'name', 'label', 'position', 'description')), + ) + class Meta: model = ModuleBayTemplate fields = [ 'device_type', 'name', 'label', 'position', 'description', ] - widgets = { - 'device_type': forms.HiddenInput(), - } -class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): +class DeviceBayTemplateForm(ComponentTemplateForm): + fieldsets = ( + (None, ('device_type', 'name', 'label', 'description')), + ) + class Meta: model = DeviceBayTemplate fields = [ 'device_type', 'name', 'label', 'description', ] - widgets = { - 'device_type': forms.HiddenInput(), - } -class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): +class InventoryItemTemplateForm(ComponentTemplateForm): parent = DynamicModelChoiceField( queryset=InventoryItemTemplate.objects.all(), required=False, @@ -1147,22 +1200,39 @@ class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): widget=forms.HiddenInput ) + fieldsets = ( + (None, ( + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + )), + ) + class Meta: model = InventoryItemTemplate fields = [ 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', 'component_type', 'component_id', ] - widgets = { - 'device_type': forms.HiddenInput(), - } # # Device components # -class ConsolePortForm(NetBoxModelForm): +class DeviceComponentForm(NetBoxModelForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all() + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of Device when editing an existing instance + if self.instance.pk: + self.fields['device'].disabled = True + + +class ModularDeviceComponentForm(DeviceComponentForm): module = DynamicModelChoiceField( queryset=Module.objects.all(), required=False, @@ -1171,25 +1241,31 @@ class ConsolePortForm(NetBoxModelForm): } ) + +class ConsolePortForm(ModularDeviceComponentForm): + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + )), + ) + class Meta: model = ConsolePort fields = [ 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': StaticSelect(), } -class ConsoleServerPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class ConsoleServerPortForm(ModularDeviceComponentForm): + + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', + )), ) class Meta: @@ -1198,42 +1274,32 @@ class ConsoleServerPortForm(NetBoxModelForm): 'device', 'module', 'name', 'label', 'type', 'speed', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': StaticSelect(), } -class PowerPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class PowerPortForm(ModularDeviceComponentForm): + + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', + 'description', 'tags', + )), ) class Meta: model = PowerPort fields = [ 'device', 'module', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected', - 'description', - 'tags', + 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class PowerOutletForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class PowerOutletForm(ModularDeviceComponentForm): power_port = DynamicModelChoiceField( queryset=PowerPort.objects.all(), required=False, @@ -1242,6 +1308,13 @@ class PowerOutletForm(NetBoxModelForm): } ) + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description', + 'tags', + )), + ) + class Meta: model = PowerOutlet fields = [ @@ -1249,20 +1322,12 @@ class PowerOutletForm(NetBoxModelForm): 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'feed_leg': StaticSelect(), } -class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm): parent = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, @@ -1330,8 +1395,14 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): label='VRF' ) + wwn = forms.CharField( + empty_value=None, + required=False, + label='WWN' + ) + fieldsets = ( - ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), + ('Interface', ('device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')), ('Related Interfaces', ('parent', 'bridge', 'lag')), @@ -1351,7 +1422,6 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), 'speed': SelectSpeedWidget(), 'poe_mode': StaticSelect(), @@ -1370,25 +1440,8 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'rf_channel_width': "Populated by selected channel (if set)", } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - # Restrict LAG/bridge interface assignment by device/VC - device_id = self.data['device'] if self.is_bound else self.initial.get('device') - device = Device.objects.filter(pk=device_id).first() - if device and device.virtual_chassis and device.virtual_chassis.master: - self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk) - - -class FrontPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } - ) +class FrontPortForm(ModularDeviceComponentForm): rear_port = DynamicModelChoiceField( queryset=RearPort.objects.all(), query_params={ @@ -1396,6 +1449,13 @@ class FrontPortForm(NetBoxModelForm): } ) + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'mark_connected', + 'description', 'tags', + )), + ) + class Meta: model = FrontPort fields = [ @@ -1403,18 +1463,15 @@ class FrontPortForm(NetBoxModelForm): 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class RearPortForm(NetBoxModelForm): - module = DynamicModelChoiceField( - queryset=Module.objects.all(), - required=False, - query_params={ - 'device_id': '$device', - } +class RearPortForm(ModularDeviceComponentForm): + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', + )), ) class Meta: @@ -1423,33 +1480,32 @@ class RearPortForm(NetBoxModelForm): 'device', 'module', 'name', 'label', 'type', 'color', 'positions', 'mark_connected', 'description', 'tags', ] widgets = { - 'device': forms.HiddenInput(), 'type': StaticSelect(), } -class ModuleBayForm(NetBoxModelForm): +class ModuleBayForm(DeviceComponentForm): + fieldsets = ( + (None, ('device', 'name', 'label', 'position', 'description', 'tags',)), + ) class Meta: model = ModuleBay fields = [ 'device', 'name', 'label', 'position', 'description', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } -class DeviceBayForm(NetBoxModelForm): +class DeviceBayForm(DeviceComponentForm): + fieldsets = ( + (None, ('device', 'name', 'label', 'description', 'tags',)), + ) class Meta: model = DeviceBay fields = [ 'device', 'name', 'label', 'description', 'tags', ] - widgets = { - 'device': forms.HiddenInput(), - } class PopulateDeviceBayForm(BootstrapMixin, forms.Form): @@ -1472,10 +1528,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) -class InventoryItemForm(NetBoxModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) +class InventoryItemForm(DeviceComponentForm): parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d2c941b34..a03597db1 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -2,46 +2,56 @@ from django import forms from dcim.models import * from netbox.forms import NetBoxModelForm -from utilities.forms import ( - BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, -) +from utilities.forms import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from . import models as model_forms __all__ = ( - 'ComponentTemplateCreateForm', - 'DeviceComponentCreateForm', + 'ComponentCreateForm', + 'ConsolePortCreateForm', + 'ConsolePortTemplateCreateForm', + 'ConsoleServerPortCreateForm', + 'ConsoleServerPortTemplateCreateForm', + 'DeviceBayCreateForm', + 'DeviceBayTemplateCreateForm', 'FrontPortCreateForm', 'FrontPortTemplateCreateForm', + 'InterfaceCreateForm', + 'InterfaceTemplateCreateForm', 'InventoryItemCreateForm', - 'ModularComponentTemplateCreateForm', + 'InventoryItemTemplateCreateForm', 'ModuleBayCreateForm', 'ModuleBayTemplateCreateForm', + 'PowerOutletCreateForm', + 'PowerOutletTemplateCreateForm', + 'PowerPortCreateForm', + 'PowerPortTemplateCreateForm', + 'RearPortCreateForm', + 'RearPortTemplateCreateForm', 'VirtualChassisCreateForm', ) -class ComponentCreateForm(BootstrapMixin, forms.Form): +class ComponentCreateForm(forms.Form): """ - Subclass this form when facilitating the creation of one or more device component or component templates based on + Subclass this form when facilitating the creation of one or more component or component template objects based on a name pattern. """ - name_pattern = ExpandableNameField( - label='Name' - ) - label_pattern = ExpandableNameField( - label='Label', + name = ExpandableNameField() + label = ExpandableNameField( required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' ) + # Identify the fields which support replication (i.e. ExpandableNameFields). This is referenced by + # ComponentCreateView when creating objects. + replication_fields = ('name', 'label') + def clean(self): super().clean() - # Validate that all patterned fields generate an equal number of values - patterned_fields = [ - field_name for field_name in self.fields if field_name.endswith('_pattern') - ] - pattern_count = len(self.cleaned_data['name_pattern']) - for field_name in patterned_fields: + # Validate that all replication fields generate an equal number of values + pattern_count = len(self.cleaned_data[self.replication_fields[0]]) + for field_name in self.replication_fields: value_count = len(self.cleaned_data[field_name]) if self.cleaned_data[field_name] and value_count != pattern_count: raise forms.ValidationError({ @@ -50,56 +60,55 @@ class ComponentCreateForm(BootstrapMixin, forms.Form): }, code='label_pattern_mismatch') -class ComponentTemplateCreateForm(ComponentCreateForm): - """ - Creation form for component templates that can be assigned only to a DeviceType. - """ - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - ) - field_order = ('device_type', 'name_pattern', 'label_pattern') +# +# Device component templates +# + +class ConsolePortTemplateCreateForm(ComponentCreateForm, model_forms.ConsolePortTemplateForm): + + class Meta(model_forms.ConsolePortTemplateForm.Meta): + exclude = ('name', 'label') -class ModularComponentTemplateCreateForm(ComponentCreateForm): - """ - Creation form for component templates that can be assigned to either a DeviceType *or* a ModuleType. - """ - name_pattern = ExpandableNameField( - label='Name', - help_text=""" - Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Example: [ge,xe]-0/0/[0-9]. {module} is accepted as a substitution for - the module bay position. - """ - ) - device_type = DynamicModelChoiceField( - queryset=DeviceType.objects.all(), - required=False - ) - module_type = DynamicModelChoiceField( - queryset=ModuleType.objects.all(), - required=False - ) - field_order = ('device_type', 'module_type', 'name_pattern', 'label_pattern') +class ConsoleServerPortTemplateCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortTemplateForm): + + class Meta(model_forms.ConsoleServerPortTemplateForm.Meta): + exclude = ('name', 'label') -class DeviceComponentCreateForm(ComponentCreateForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all() - ) - field_order = ('device', 'name_pattern', 'label_pattern') +class PowerPortTemplateCreateForm(ComponentCreateForm, model_forms.PowerPortTemplateForm): + + class Meta(model_forms.PowerPortTemplateForm.Meta): + exclude = ('name', 'label') -class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): - rear_port_set = forms.MultipleChoiceField( +class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutletTemplateForm): + + class Meta(model_forms.PowerOutletTemplateForm.Meta): + exclude = ('name', 'label') + + +class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm): + + class Meta(model_forms.InterfaceTemplateForm.Meta): + exclude = ('name', 'label') + + +class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm): + rear_port = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ( - 'device_type', 'name_pattern', 'label_pattern', 'rear_port_set', + + # Override fieldsets from FrontPortTemplateForm to omit rear_port_position + fieldsets = ( + (None, ('device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'description')), ) + class Meta(model_forms.FrontPortTemplateForm.Meta): + exclude = ('name', 'label', 'rear_port', 'rear_port_position') + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -130,12 +139,12 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): choices.append( ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) ) - self.fields['rear_port_set'].choices = choices + self.fields['rear_port'].choices = choices def get_iterative_data(self, iteration): # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') return { 'rear_port': int(rear_port), @@ -143,16 +152,94 @@ class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm): } -class FrontPortCreateForm(DeviceComponentCreateForm): - rear_port_set = forms.MultipleChoiceField( +class RearPortTemplateCreateForm(ComponentCreateForm, model_forms.RearPortTemplateForm): + + class Meta(model_forms.RearPortTemplateForm.Meta): + exclude = ('name', 'label') + + +class DeviceBayTemplateCreateForm(ComponentCreateForm, model_forms.DeviceBayTemplateForm): + + class Meta(model_forms.DeviceBayTemplateForm.Meta): + exclude = ('name', 'label') + + +class ModuleBayTemplateCreateForm(ComponentCreateForm, model_forms.ModuleBayTemplateForm): + position = ExpandableNameField( + label='Position', + required=False, + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' + ) + replication_fields = ('name', 'label', 'position') + + class Meta(model_forms.ModuleBayTemplateForm.Meta): + exclude = ('name', 'label', 'position') + + +class InventoryItemTemplateCreateForm(ComponentCreateForm, model_forms.InventoryItemTemplateForm): + + class Meta(model_forms.InventoryItemTemplateForm.Meta): + exclude = ('name', 'label') + + +# +# Device components +# + +class ConsolePortCreateForm(ComponentCreateForm, model_forms.ConsolePortForm): + + class Meta(model_forms.ConsolePortForm.Meta): + exclude = ('name', 'label') + + +class ConsoleServerPortCreateForm(ComponentCreateForm, model_forms.ConsoleServerPortForm): + + class Meta(model_forms.ConsoleServerPortForm.Meta): + exclude = ('name', 'label') + + +class PowerPortCreateForm(ComponentCreateForm, model_forms.PowerPortForm): + + class Meta(model_forms.PowerPortForm.Meta): + exclude = ('name', 'label') + + +class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm): + + class Meta(model_forms.PowerOutletForm.Meta): + exclude = ('name', 'label') + + +class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): + + class Meta(model_forms.InterfaceForm.Meta): + exclude = ('name', 'label') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if 'module' in self.fields: + self.fields['name'].help_text += ' The string {module} will be replaced with the position ' \ + 'of the assigned module, if any' + + +class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): + rear_port = forms.MultipleChoiceField( choices=[], label='Rear ports', help_text='Select one rear port assignment for each front port being created.', ) - field_order = ( - 'device', 'name_pattern', 'label_pattern', 'rear_port_set', + + # Override fieldsets from FrontPortForm to omit rear_port_position + fieldsets = ( + (None, ( + 'device', 'module', 'name', 'label', 'type', 'color', 'rear_port', 'mark_connected', 'description', 'tags', + )), ) + class Meta(model_forms.FrontPortForm.Meta): + exclude = ('name', 'label', 'rear_port', 'rear_port_position') + def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -176,12 +263,12 @@ class FrontPortCreateForm(DeviceComponentCreateForm): choices.append( ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i)) ) - self.fields['rear_port_set'].choices = choices + self.fields['rear_port'].choices = choices def get_iterative_data(self, iteration): # Assign rear port and position from selected set - rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':') + rear_port, position = self.cleaned_data['rear_port'][iteration].split(':') return { 'rear_port': int(rear_port), @@ -189,28 +276,39 @@ class FrontPortCreateForm(DeviceComponentCreateForm): } -class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm): - position_pattern = ExpandableNameField( +class RearPortCreateForm(ComponentCreateForm, model_forms.RearPortForm): + + class Meta(model_forms.RearPortForm.Meta): + exclude = ('name', 'label') + + +class DeviceBayCreateForm(ComponentCreateForm, model_forms.DeviceBayForm): + + class Meta(model_forms.DeviceBayForm.Meta): + exclude = ('name', 'label') + + +class ModuleBayCreateForm(ComponentCreateForm, model_forms.ModuleBayForm): + position = ExpandableNameField( label='Position', required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' + help_text='Alphanumeric ranges are supported. (Must match the number of objects being created.)' ) - field_order = ('device_type', 'name_pattern', 'label_pattern', 'position_pattern') + replication_fields = ('name', 'label', 'position') + + class Meta(model_forms.ModuleBayForm.Meta): + exclude = ('name', 'label', 'position') -class ModuleBayCreateForm(DeviceComponentCreateForm): - position_pattern = ExpandableNameField( - label='Position', - required=False, - help_text='Alphanumeric ranges are supported. (Must match the number of names being created.)' - ) - field_order = ('device', 'name_pattern', 'label_pattern', 'position_pattern') +class InventoryItemCreateForm(ComponentCreateForm, model_forms.InventoryItemForm): + + class Meta(model_forms.InventoryItemForm.Meta): + exclude = ('name', 'label') -class InventoryItemCreateForm(ComponentCreateForm): - # Device is assigned by the model form - field_order = ('name_pattern', 'label_pattern') - +# +# Virtual chassis +# class VirtualChassisCreateForm(NetBoxModelForm): region = DynamicModelChoiceField( diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 2be64451f..e05eb6d51 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -281,15 +281,11 @@ class CableTermination(models.Model): # Validate interface type (if applicable) if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces' - }) + raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces") # A CircuitTermination attached to a ProviderNetwork cannot have a Cable if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: - raise ValidationError({ - 'termination': "Circuit terminations attached to a provider network may not be cabled." - }) + raise ValidationError("Circuit terminations attached to a provider network may not be cabled.") def save(self, *args, **kwargs): @@ -677,6 +673,12 @@ class CablePath(models.Model): """ Return all available next segments in a split cable path. """ - rearports = self.path_objects[-1] + nodes = self.path_objects[-1] - return FrontPort.objects.filter(rear_port__in=rearports) + # RearPort splitting to multiple FrontPorts with no stack position + if type(nodes[0]) is RearPort: + return FrontPort.objects.filter(rear_port__in=nodes) + # Cable terminating to multiple FrontPorts mapped to different + # RearPorts connected to different cables + elif type(nodes[0]) is FrontPort: + return RearPort.objects.filter(pk__in=[fp.rear_port_id for fp in nodes]) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 838336e21..8f1285901 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -908,18 +908,20 @@ class FrontPort(ModularComponentModel, CabledObjectModel): def clean(self): super().clean() - # Validate rear port assignment - if self.rear_port.device != self.device: - raise ValidationError({ - "rear_port": f"Rear port ({self.rear_port}) must belong to the same device" - }) + if hasattr(self, 'rear_port'): - # Validate rear port position assignment - if self.rear_port_position > self.rear_port.positions: - raise ValidationError({ - "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " - f"{self.rear_port.name} has only {self.rear_port.positions} positions" - }) + # Validate rear port assignment + if self.rear_port.device != self.device: + raise ValidationError({ + "rear_port": f"Rear port ({self.rear_port}) must belong to the same device" + }) + + # Validate rear port position assignment + if self.rear_port_position > self.rear_port.positions: + raise ValidationError({ + "rear_port_position": f"Invalid rear port position ({self.rear_port_position}): Rear port " + f"{self.rear_port.name} has only {self.rear_port.positions} positions" + }) class RearPort(ModularComponentModel, CabledObjectModel): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 092df3a0e..ccf4613bf 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -168,6 +168,10 @@ class DeviceType(NetBoxModel): def get_absolute_url(self): return reverse('dcim:devicetype', args=[self.pk]) + @property + def get_full_name(self): + return f"{ self.manufacturer } { self.model }" + def to_yaml(self): data = { 'manufacturer': self.manufacturer.name, @@ -864,6 +868,7 @@ class Device(NetBoxModel, ConfigContextModel): for device in devices: device.site = self.site device.rack = self.rack + device.location = self.location device.save() @property diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 22fca8cf6..20027675a 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -350,7 +350,7 @@ class Rack(NetBoxModel): # Remove units without enough space above them to accommodate a device of the specified height available_units = [] for u in units: - if set(drange(u, u + u_height, 0.5)).issubset(units): + if set(drange(u, u + decimal.Decimal(u_height), 0.5)).issubset(units): available_units.append(u) return list(reversed(available_units)) @@ -415,12 +415,13 @@ class Rack(NetBoxModel): """ # Determine unoccupied units total_units = len(list(self.units)) - available_units = self.get_available_units() + available_units = self.get_available_units(u_height=0.5) # Remove reserved units - for u in self.get_reserved_units(): - if u in available_units: - available_units.remove(u) + for ru in self.get_reserved_units(): + for u in drange(ru, ru + 1, 0.5): + if u in available_units: + available_units.remove(u) occupied_unit_count = total_units - len(available_units) percentage = float(occupied_unit_count) / total_units * 100 diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py index 26d16fafe..3872bc4fe 100644 --- a/netbox/dcim/svg/cables.py +++ b/netbox/dcim/svg/cables.py @@ -94,7 +94,7 @@ class Connector(Group): self.add(cable) # Add link - link = Hyperlink(href=url, target='_blank') + link = Hyperlink(href=url, target='_parent') # Add text label(s) cursor = start[1] @@ -281,7 +281,7 @@ class CableTraceSVG: self.cursor += PADDING * 2 # Add link - link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_blank') + link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent') # Add text label(s) for i, label in enumerate(labels): diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 28527498f..573fc966c 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -151,7 +151,7 @@ class RackElevationSVG: css_extra = ' shaded' if is_shaded else '' # Create hyperlink element - link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank') + link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent") link.set_desc(description) # Add rect element to hyperlink @@ -235,10 +235,7 @@ class RackElevationSVG: self.margin_width, u_height * self.unit_height ) - link = Hyperlink( - href='{}{}'.format(self.base_url, reservation.get_absolute_url()), - target='_blank' - ) + link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent') link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') link.add( Rect(coords, size, class_='reservation') @@ -268,7 +265,7 @@ class RackElevationSVG: y_offset + self.unit_height / 2 ) - link = Hyperlink(href=url_string.format(unit), target='_blank') + link = Hyperlink(href=url_string.format(unit), target='_parent') link.add(Rect((x_offset, y_offset), (self.unit_width, self.unit_height), class_='slot')) link.add(Text('add device', insert=text_coords, class_='add-device')) diff --git a/netbox/dcim/tables/__init__.py b/netbox/dcim/tables/__init__.py index e3b2a42ba..843b612b1 100644 --- a/netbox/dcim/tables/__init__.py +++ b/netbox/dcim/tables/__init__.py @@ -1,109 +1,8 @@ -import django_tables2 as tables -from django_tables2.utils import Accessor - -from netbox.tables import BaseTable, columns -from dcim.models import ConsolePort, Interface, PowerPort from .cables import * +from .connections import * from .devices import * from .devicetypes import * from .modules import * from .power import * from .racks import * from .sites import * - - -# -# Device connections -# - -class ConsoleConnectionTable(BaseTable): - console_server = tables.Column( - accessor=Accessor('_path__destination__device'), - orderable=False, - linkify=True, - verbose_name='Console Server' - ) - console_server_port = tables.Column( - accessor=Accessor('_path__destination'), - orderable=False, - linkify=True, - verbose_name='Port' - ) - device = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True, - verbose_name='Console Port' - ) - reachable = columns.BooleanColumn( - accessor=Accessor('_path__is_active'), - verbose_name='Reachable' - ) - - class Meta(BaseTable.Meta): - model = ConsolePort - fields = ('device', 'name', 'console_server', 'console_server_port', 'reachable') - - -class PowerConnectionTable(BaseTable): - pdu = tables.Column( - accessor=Accessor('_path__destination__device'), - orderable=False, - linkify=True, - verbose_name='PDU' - ) - outlet = tables.Column( - accessor=Accessor('_path__destination'), - orderable=False, - linkify=True, - verbose_name='Outlet' - ) - device = tables.Column( - linkify=True - ) - name = tables.Column( - linkify=True, - verbose_name='Power Port' - ) - reachable = columns.BooleanColumn( - accessor=Accessor('_path__is_active'), - verbose_name='Reachable' - ) - - class Meta(BaseTable.Meta): - model = PowerPort - fields = ('device', 'name', 'pdu', 'outlet', 'reachable') - - -class InterfaceConnectionTable(BaseTable): - device_a = tables.Column( - accessor=Accessor('device'), - linkify=True, - verbose_name='Device A' - ) - interface_a = tables.Column( - accessor=Accessor('name'), - linkify=True, - verbose_name='Interface A' - ) - device_b = tables.Column( - accessor=Accessor('_path__destination__device'), - orderable=False, - linkify=True, - verbose_name='Device B' - ) - interface_b = tables.Column( - accessor=Accessor('_path__destination'), - orderable=False, - linkify=True, - verbose_name='Interface B' - ) - reachable = columns.BooleanColumn( - accessor=Accessor('_path__is_active'), - verbose_name='Reachable' - ) - - class Meta(BaseTable.Meta): - model = Interface - fields = ('device_a', 'interface_a', 'device_b', 'interface_b', 'reachable') diff --git a/netbox/dcim/tables/connections.py b/netbox/dcim/tables/connections.py new file mode 100644 index 000000000..f9f78f3a6 --- /dev/null +++ b/netbox/dcim/tables/connections.py @@ -0,0 +1,71 @@ +import django_tables2 as tables +from django_tables2.utils import Accessor + +from netbox.tables import BaseTable, columns +from dcim.models import ConsolePort, Interface, PowerPort +from .devices import PathEndpointTable + +__all__ = ( + 'ConsoleConnectionTable', + 'InterfaceConnectionTable', + 'PowerConnectionTable', +) + + +# +# Device connections +# + +class ConsoleConnectionTable(PathEndpointTable): + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + verbose_name='Console Port' + ) + reachable = columns.BooleanColumn( + accessor=Accessor('_path__is_active'), + verbose_name='Reachable' + ) + + class Meta(BaseTable.Meta): + model = ConsolePort + fields = ('device', 'name', 'connection', 'reachable') + + +class PowerConnectionTable(PathEndpointTable): + device = tables.Column( + linkify=True + ) + name = tables.Column( + linkify=True, + verbose_name='Power Port' + ) + reachable = columns.BooleanColumn( + accessor=Accessor('_path__is_active'), + verbose_name='Reachable' + ) + + class Meta(BaseTable.Meta): + model = PowerPort + fields = ('device', 'name', 'connection', 'reachable') + + +class InterfaceConnectionTable(PathEndpointTable): + device = tables.Column( + accessor=Accessor('device'), + linkify=True + ) + interface = tables.Column( + accessor=Accessor('name'), + linkify=True + ) + reachable = columns.BooleanColumn( + accessor=Accessor('_path__is_active'), + verbose_name='Reachable' + ) + + class Meta(BaseTable.Meta): + model = Interface + fields = ('device', 'interface', 'connection', 'reachable') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index c1515a15f..142c7ef67 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -143,6 +143,15 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): template_code=DEVICE_LINK ) status = columns.ChoiceFieldColumn() + region = tables.Column( + accessor=Accessor('site__region'), + linkify=True + ) + site_group = tables.Column( + accessor=Accessor('site__group'), + linkify=True, + verbose_name='Site Group' + ) site = tables.Column( linkify=True ) @@ -152,6 +161,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): rack = tables.Column( linkify=True ) + position = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) device_role = columns.ColoredLabelColumn( verbose_name='Role' ) @@ -199,10 +211,10 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Device fields = ( - 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', + 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', + 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', + 'vc_priority', 'comments', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -483,6 +495,12 @@ class BaseInterfaceTable(NetBoxTable): orderable=False, verbose_name='FHRP Groups' ) + l2vpn = tables.Column( + accessor=tables.A('l2vpn_termination__l2vpn'), + linkify=True, + orderable=False, + verbose_name='L2VPN' + ) untagged_vlan = tables.Column(linkify=True) tagged_vlans = columns.TemplateColumn( template_code=INTERFACE_TAGGED_VLANS, @@ -520,8 +538,8 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi 'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'speed', 'duplex', 'mode', 'mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', - 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'ip_addresses', - 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', + 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description') @@ -554,8 +572,8 @@ class DeviceInterfaceTable(InterfaceTable): 'pk', 'id', 'name', 'module_bay', 'module', 'label', 'enabled', 'type', 'parent', 'bridge', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', - 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', - 'tagged_vlans', 'actions', + 'wireless_lans', 'link_peer', 'connection', 'tags', 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', + 'untagged_vlan', 'tagged_vlans', 'actions', ) order_by = ('name',) default_columns = ( diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index d83f25a5f..39553bac0 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -51,7 +51,7 @@ class RackTable(TenancyColumnsMixin, NetBoxTable): status = columns.ChoiceFieldColumn() role = columns.ColoredLabelColumn() u_height = tables.TemplateColumn( - template_code="{{ record.u_height }}U", + template_code="{{ value }}U", verbose_name='Height' ) comments = columns.MarkdownColumn() diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 3403f9392..dfc77b854 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -4,7 +4,7 @@ LINKTERMINATION = """ {{ termination.parent_object }} {% endif %} - {{ termination }}{% if not forloop.last %},{% endif %} + {{ termination }}{% if not forloop.last %}
{% endif %} {% empty %} {{ ''|placeholder }} {% endfor %} @@ -33,7 +33,7 @@ DEVICEBAY_STATUS = """ INTERFACE_IPADDRESSES = """
- {% for ip in record.ip_addresses.all %} + {% for ip in value.all %} {% if ip.status != 'active' %} {{ ip }} {% else %} @@ -53,7 +53,7 @@ INTERFACE_FHRPGROUPS = """ INTERFACE_TAGGED_VLANS = """ {% if record.mode == 'tagged' %} - {% for vlan in record.tagged_vlans.all %} + {% for vlan in value.all %} {{ vlan }}
{% endfor %} {% elif record.mode == 'tagged-all' %} @@ -62,7 +62,7 @@ INTERFACE_TAGGED_VLANS = """ """ INTERFACE_WIRELESS_LANS = """ -{% for wlan in record.wireless_lans.all %} +{% for wlan in value.all %} {{ wlan }}
{% endfor %} """ @@ -226,7 +226,7 @@ POWEROUTLET_BUTTONS = """ """ INTERFACE_BUTTONS = """ -{% if perms.ipam.add_ipaddress or perms.dcim.add_inventoryitem %} +{% if perms.dcim.change_interface %} + {% else %} + + {% endif %} Cancel -
diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 2611686f6..364b50777 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -22,16 +22,18 @@

Path split!

Select a node below to continue:

{% else %}

Trace Completed

diff --git a/netbox/templates/dcim/component_template_create.html b/netbox/templates/dcim/component_template_create.html deleted file mode 100644 index d164db872..000000000 --- a/netbox/templates/dcim/component_template_create.html +++ /dev/null @@ -1,38 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} - {% if form.module_type %} -
-
- -
-
-
-
- {% render_field replication_form.device_type %} -
-
- {% render_field replication_form.module_type %} -
-
- {% else %} - {% render_field replication_form.device_type %} - {% endif %} - {% block replication_fields %} - {% render_field replication_form.name_pattern %} - {% render_field replication_form.label_pattern %} - {% endblock replication_fields %} - {{ block.super }} -{% endblock form %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2df2407b5..6cc859749 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -46,10 +46,10 @@ Rack - + {% if object.rack %} {{ object.rack|linkify }} -
+
@@ -70,7 +70,7 @@ {% endif %} {% endwith %} {% elif object.rack and object.position %} - U{{ object.position }} / {{ object.get_face_display }} + U{{ object.position|floatformat }} / {{ object.get_face_display }} {% elif object.rack and object.device_type.u_height %} Not racked {% else %} @@ -90,7 +90,7 @@ Device Type - {{ object.device_type|linkify }} ({{ object.device_type.u_height }}U) + {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height }}U) diff --git a/netbox/templates/dcim/device/lldp_neighbors.html b/netbox/templates/dcim/device/lldp_neighbors.html index adf8efdce..2be6aba4d 100644 --- a/netbox/templates/dcim/device/lldp_neighbors.html +++ b/netbox/templates/dcim/device/lldp_neighbors.html @@ -32,23 +32,25 @@ {% for iface in interfaces %} {{ iface }} - {% if iface.connected_endpoint.device %} - - {{ iface.connected_endpoint.device }} - - - {{ iface.connected_endpoint }} - - {% elif iface.connected_endpoint.circuit %} - {% with circuit=iface.connected_endpoint.circuit %} - - - {{ circuit.provider }} {{ circuit }} - - {% endwith %} - {% else %} - None - {% endif %} + {% with peer=iface.connected_endpoints.0 %} + {% if peer.device %} + + {{ peer.device }} + + + {{ peer }} + + {% elif peer.circuit %} + {% with circuit=peer.circuit %} + + + {{ circuit.provider }} {{ circuit }} + + {% endwith %} + {% else %} + None + {% endif %} + {% endwith %} diff --git a/netbox/templates/dcim/device_component_edit.html b/netbox/templates/dcim/device_component_edit.html deleted file mode 100644 index 44b93d870..000000000 --- a/netbox/templates/dcim/device_component_edit.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'generic/object_edit.html' %} -{% load form_helpers %} - -{% block form %} -
- {% if form.instance.device %} -
- -
- -
-
- {% endif %} - {% render_form form %} -
-{% endblock form %} diff --git a/netbox/templates/dcim/frontporttemplate_create.html b/netbox/templates/dcim/frontporttemplate_create.html deleted file mode 100644 index 50e9d355c..000000000 --- a/netbox/templates/dcim/frontporttemplate_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% render_field replication_form.rear_port_set %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html deleted file mode 100644 index be910f143..000000000 --- a/netbox/templates/dcim/inventoryitem_create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'dcim/component_create.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% if object.component %} -
- -
- -
-
- {% endif %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/inventoryitemtemplate_create.html b/netbox/templates/dcim/inventoryitemtemplate_create.html deleted file mode 100644 index 9180cf6ab..000000000 --- a/netbox/templates/dcim/inventoryitemtemplate_create.html +++ /dev/null @@ -1,17 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load helpers %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% if object.component %} -
- -
- -
-
- {% endif %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index f0335036f..10cec1548 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -58,9 +58,9 @@ Racks - + {% if rack_count %} -
+
diff --git a/netbox/templates/dcim/modulebaytemplate_create.html b/netbox/templates/dcim/modulebaytemplate_create.html deleted file mode 100644 index 74323ac4b..000000000 --- a/netbox/templates/dcim/modulebaytemplate_create.html +++ /dev/null @@ -1,7 +0,0 @@ -{% extends 'dcim/component_template_create.html' %} -{% load form_helpers %} - -{% block replication_fields %} - {{ block.super }} - {% render_field replication_form.position_pattern %} -{% endblock replication_fields %} diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index 87a047900..ef22bd9b8 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -18,9 +18,15 @@ Front Rear
-
- Normal - Reversed +
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index ab04ea018..a4ee4180f 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -85,11 +85,11 @@ Physical Address - + {% if object.physical_address %} -
+ {{ object.physical_address|linebreaksbr }} @@ -104,9 +104,9 @@ GPS Coordinates - + {% if object.latitude and object.longitude %} -
+
Map It diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 275391c61..87917f2a2 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -55,7 +55,7 @@ {{ device.pk }} {% if device.rack %} - {{ device.rack }} / {{ device.position }} + {{ device.rack }} / {{ device.position|floatformat }} {% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 8884ff77c..1f34f4d5e 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -34,7 +34,7 @@ {% for class_name, script in module_scripts.items %} - {{ script.name }} + {{ script.name }} {% include 'extras/inc/job_label.html' with result=script.result %} diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html index 8047dc59d..56e4f5a32 100644 --- a/netbox/templates/generic/object_edit.html +++ b/netbox/templates/generic/object_edit.html @@ -31,6 +31,11 @@ Context:
+ {# Warn about missing prerequisite objects #} + {% if prerequisite_model %} + {% include 'inc/missing_prerequisites.html' %} + {% endif %} + {# Link to model documentation #} {% if object and settings.DOCS_ROOT %}
@@ -40,10 +45,6 @@ Context:
{% endif %} - {% if prerequisite_model %} - {% include 'inc/missing_prerequisites.html' %} - {% endif %} -
{% csrf_token %} @@ -58,9 +59,11 @@ Context: {# Render grouped fields according to Form #} {% for group, fields in form.fieldsets %}
-
-
{{ group }}
-
+ {% if group %} +
+
{{ group }}
+
+ {% endif %} {% for name in fields %} {% with field=form|getfield:name %} {% if not field.field.widget.is_hidden %} diff --git a/netbox/templates/inc/missing_prerequisites.html b/netbox/templates/inc/missing_prerequisites.html index 5814b72eb..66736a53e 100644 --- a/netbox/templates/inc/missing_prerequisites.html +++ b/netbox/templates/inc/missing_prerequisites.html @@ -1,6 +1,9 @@ {% load buttons %} -
+ {% if form.custom_fields %} +
+
+
Custom Fields
+
+ {% render_custom_fields form %} +
+{% endif %} {% endblock %} diff --git a/netbox/templates/login.html b/netbox/templates/login.html index f4dd9c696..66b519671 100644 --- a/netbox/templates/login.html +++ b/netbox/templates/login.html @@ -13,6 +13,16 @@
{% endif %} + {# Login form errors #} + {% if form.non_field_errors %} + + {% endif %} + {# Login form #} {% endif %} {% for name in fields %} - {% render_field form|getfield:name %} + {% if name in form.nullable_fields %} + {% render_field form|getfield:name bulk_nullable=True %} + {% else %} + {% render_field form|getfield:name %} + {% endif %} {% endfor %} {% endfor %} diff --git a/netbox/utilities/testing/views.py b/netbox/utilities/testing/views.py index 7fa9f66bc..93cb88088 100644 --- a/netbox/utilities/testing/views.py +++ b/netbox/utilities/testing/views.py @@ -466,6 +466,7 @@ class ViewTestCases: """ bulk_create_count = 3 bulk_create_data = {} + validation_excluded_fields = [] @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_multiple_objects_without_permission(self): @@ -500,7 +501,7 @@ class ViewTestCases: self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_multiple_objects_with_constrained_permission(self): @@ -532,7 +533,7 @@ class ViewTestCases: self.assertHttpStatus(response, 302) self.assertEqual(initial_count + self.bulk_create_count, self._get_queryset().count()) for instance in self._get_queryset().order_by('-pk')[:self.bulk_create_count]: - self.assertInstanceEqual(instance, self.bulk_create_data) + self.assertInstanceEqual(instance, self.bulk_create_data, exclude=self.validation_excluded_fields) class BulkImportObjectsViewTestCase(ModelViewTestCase): """ diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 1dece76c8..69ab615fc 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -285,7 +285,7 @@ def prepare_cloned_fields(instance): """ # Generate the clone attributes from the instance if not hasattr(instance, 'clone'): - return QueryDict() + return QueryDict(mutable=True) attrs = instance.clone() # Prepare querydict parameters diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 903d89a07..b88bc7712 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -100,7 +100,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer): 'tags', 'custom_fields', 'config_context', 'created', 'last_updated', ] - @swagger_serializer_method(serializer_or_field=serializers.DictField) + @swagger_serializer_method(serializer_or_field=serializers.JSONField) def get_config_context(self, obj): return obj.get_config_context() diff --git a/netbox/virtualization/forms/bulk_create.py b/netbox/virtualization/forms/bulk_create.py index 6cf7c0d7c..03997f88d 100644 --- a/netbox/virtualization/forms/bulk_create.py +++ b/netbox/virtualization/forms/bulk_create.py @@ -13,7 +13,7 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): queryset=VirtualMachine.objects.all(), widget=forms.MultipleHiddenInput() ) - name_pattern = ExpandableNameField( + name = ExpandableNameField( label='Name' ) @@ -27,4 +27,4 @@ class VMInterfaceBulkCreateForm( form_from_model(VMInterface, ['enabled', 'mtu', 'description', 'tags']), VirtualMachineBulkAddComponentForm ): - pass + replication_fields = ('name',) diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 723c19332..268afb9bb 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -5,7 +5,6 @@ from django.core.exceptions import ValidationError from dcim.forms.common import InterfaceCommonForm from dcim.forms.models import INTERFACE_MODE_HELP_TEXT from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup -from extras.models import Tag from ipam.models import IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm @@ -278,6 +277,9 @@ class VirtualMachineForm(TenancyForm, NetBoxModelForm): class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): + virtual_machine = DynamicModelChoiceField( + queryset=VirtualMachine.objects.all() + ) parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, @@ -323,6 +325,14 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): label='VRF' ) + fieldsets = ( + ('Interface', ('virtual_machine', 'name', 'description', 'tags')), + ('Addressing', ('vrf', 'mac_address')), + ('Operation', ('mtu', 'enabled')), + ('Related Interfaces', ('parent', 'bridge')), + ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), + ) + class Meta: model = VMInterface fields = [ @@ -330,7 +340,6 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'tags', ] widgets = { - 'virtual_machine': forms.HiddenInput(), 'mode': StaticSelect() } labels = { @@ -339,3 +348,10 @@ class VMInterfaceForm(InterfaceCommonForm, NetBoxModelForm): help_texts = { 'mode': INTERFACE_MODE_HELP_TEXT, } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Disable reassignment of VirtualMachine when editing an existing instance + if self.instance.pk: + self.fields['virtual_machine'].disabled = True diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index feab3bb3a..79457a56e 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,17 +1,14 @@ -from django import forms - -from utilities.forms import BootstrapMixin, DynamicModelChoiceField, ExpandableNameField -from .models import VirtualMachine +from utilities.forms import ExpandableNameField +from .models import VMInterfaceForm __all__ = ( 'VMInterfaceCreateForm', ) -class VMInterfaceCreateForm(BootstrapMixin, forms.Form): - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all() - ) - name_pattern = ExpandableNameField( - label='Name' - ) +class VMInterfaceCreateForm(VMInterfaceForm): + name = ExpandableNameField() + replication_fields = ('name',) + + class Meta(VMInterfaceForm.Meta): + exclude = ('name',) diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index b8131c1ce..abad57f88 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -368,9 +368,14 @@ class VirtualMachine(NetBoxModel, ConfigContextModel): # Validate primary IP addresses interfaces = self.interfaces.all() - for field in ['primary_ip4', 'primary_ip6']: + for family in (4, 6): + field = f'primary_ip{family}' ip = getattr(self, field) if ip is not None: + if ip.address.version != family: + raise ValidationError({ + field: f"Must be an IPv{family} address. ({ip} is an IPv{ip.address.version} address.)", + }) if ip.assigned_object in interfaces: pass elif ip.nat_inside is not None and ip.nat_inside.assigned_object in interfaces: diff --git a/netbox/virtualization/tables/virtualmachines.py b/netbox/virtualization/tables/virtualmachines.py index 410c0f541..dfd01696e 100644 --- a/netbox/virtualization/tables/virtualmachines.py +++ b/netbox/virtualization/tables/virtualmachines.py @@ -12,10 +12,23 @@ __all__ = ( ) VMINTERFACE_BUTTONS = """ -{% if perms.ipam.add_ipaddress %} - - - +{% if perms.virtualization.change_vminterface %} + + + + {% endif %} """ @@ -95,7 +108,7 @@ class VMInterfaceTable(BaseInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'vrf', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', + 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description') @@ -116,7 +129,7 @@ class VirtualMachineVMInterfaceTable(VMInterfaceTable): model = VMInterface fields = ( 'pk', 'id', 'name', 'enabled', 'parent', 'bridge', 'mac_address', 'mtu', 'mode', 'description', 'tags', - 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', + 'vrf', 'l2vpn', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'actions', ) default_columns = ('pk', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'ip_addresses') row_attrs = { diff --git a/netbox/virtualization/tests/test_views.py b/netbox/virtualization/tests/test_views.py index 01d4394f3..d00ceb5a2 100644 --- a/netbox/virtualization/tests/test_views.py +++ b/netbox/virtualization/tests/test_views.py @@ -251,6 +251,7 @@ class VirtualMachineTestCase(ViewTestCases.PrimaryObjectViewTestCase): class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): model = VMInterface + validation_excluded_fields = ('name',) @classmethod def setUpTestData(cls): @@ -290,10 +291,10 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): tags = create_tags('Alpha', 'Bravo', 'Charlie') cls.form_data = { - 'virtual_machine': virtualmachines[1].pk, + 'virtual_machine': virtualmachines[0].pk, 'name': 'Interface X', 'enabled': False, - 'bridge': interfaces[3].pk, + 'bridge': interfaces[1].pk, 'mac_address': EUI('01-02-03-04-05-06'), 'mtu': 65000, 'description': 'New description', @@ -306,7 +307,7 @@ class VMInterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'virtual_machine': virtualmachines[1].pk, - 'name_pattern': 'Interface [4-6]', + 'name': 'Interface [4-6]', 'enabled': False, 'bridge': interfaces[3].pk, 'mac_address': EUI('01-02-03-04-05-06'), diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 5b26f8503..611725d62 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -451,13 +451,11 @@ class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm - patterned_fields = ('name',) class VMInterfaceEditView(generic.ObjectEditView): queryset = VMInterface.objects.all() form = forms.VMInterfaceForm - template_name = 'virtualization/vminterface_edit.html' class VMInterfaceDeleteView(generic.ObjectDeleteView): diff --git a/netbox/wireless/migrations/0005_wirelesslink_interface_types.py b/netbox/wireless/migrations/0005_wirelesslink_interface_types.py new file mode 100644 index 000000000..0b3f88c5b --- /dev/null +++ b/netbox/wireless/migrations/0005_wirelesslink_interface_types.py @@ -0,0 +1,24 @@ +from django.db import migrations, models +import django.db.models.deletion +import wireless.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0161_cabling_cleanup'), + ('wireless', '0004_wireless_tenancy'), + ] + + operations = [ + migrations.AlterField( + model_name='wirelesslink', + name='interface_a', + field=models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface'), + ), + migrations.AlterField( + model_name='wirelesslink', + name='interface_b', + field=models.ForeignKey(limit_choices_to=wireless.models.get_wireless_interface_types, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='dcim.interface'), + ), + ] diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 36410b83b..c383ad642 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -24,7 +24,8 @@ class WirelessAuthenticationBase(models.Model): auth_type = models.CharField( max_length=50, choices=WirelessAuthTypeChoices, - blank=True + blank=True, + verbose_name="Auth Type", ) auth_cipher = models.CharField( max_length=50, @@ -127,21 +128,29 @@ class WirelessLAN(WirelessAuthenticationBase, NetBoxModel): return reverse('wireless:wirelesslan', args=[self.pk]) +def get_wireless_interface_types(): + # Wrap choices in a callable to avoid generating dummy migrations + # when the choices are updated. + return {'type__in': WIRELESS_IFACE_TYPES} + + class WirelessLink(WirelessAuthenticationBase, NetBoxModel): """ A point-to-point connection between two wireless Interfaces. """ interface_a = models.ForeignKey( to='dcim.Interface', - limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, - related_name='+' + related_name='+', + verbose_name="Interface A", ) interface_b = models.ForeignKey( to='dcim.Interface', - limit_choices_to={'type__in': WIRELESS_IFACE_TYPES}, + limit_choices_to=get_wireless_interface_types, on_delete=models.PROTECT, - related_name='+' + related_name='+', + verbose_name="Interface B", ) ssid = models.CharField( max_length=SSID_MAX_LENGTH, diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..177b44d86 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +# See PEP 518 for the spec of this file +# https://www.python.org/dev/peps/pep-0518/ + +[tool.black] +line-length = 120 +target_version = ['py38', 'py39', 'py310'] +skip-string-normalization = true + +[tool.isort] +profile = "black" + +[tool.pylint] +max-line-length = 120 + +[tool.pyright] +include = ["netbox"] +exclude = [ + "**/node_modules", + "**/__pycache__", +] +reportMissingImports = true +reportMissingTypeStubs = false diff --git a/requirements.txt b/requirements.txt index ebe5c3b8b..f868c4f0d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,7 @@ bleach==5.0.1 Django==4.0.7 django-cors-headers==3.13.0 -django-debug-toolbar==3.5.0 +django-debug-toolbar==3.6.0 django-filter==22.1 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.13.4 @@ -19,13 +19,13 @@ graphene-django==2.15.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.4.1 -mkdocs-material==8.4.0 +mkdocs-material==8.5.1 mkdocstrings[python-legacy]==0.19.0 netaddr==0.8.0 Pillow==9.2.0 psycopg2-binary==2.9.3 PyYAML==6.0 -sentry-sdk==1.9.5 +sentry-sdk==1.9.8 social-auth-app-django==5.0.0 social-auth-core==4.3.0 svgwrite==1.4.3 diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 7a3d680a4..2ccf8df89 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -40,10 +40,13 @@ if [ $? != 0 ]; then EXIT=1 fi -echo "Checking UI ESLint, TypeScript, and Prettier compliance..." -yarn --cwd "$PWD/netbox/project-static" validate -if [ $? != 0 ]; then - EXIT=1 +git diff --cached --name-only | if grep --quiet 'netbox/project-static/' +then + echo "Checking UI ESLint, TypeScript, and Prettier compliance..." + yarn --cwd "$PWD/netbox/project-static" validate + if [ $? != 0 ]; then + EXIT=1 + fi fi if [ $EXIT != 0 ]; then