Przeglądaj źródła

Merge remote-tracking branch 'origin/feature' into 22462-l2vpn-ip-termination

# Conflicts:
#	netbox/vpn/filtersets.py
#	netbox/vpn/tests/test_filtersets.py
Brian Tiemann 16 godzin temu
rodzic
commit
d1be82e634
100 zmienionych plików z 4299 dodań i 319 usunięć
  1. 1 1
      .claude/skills/add-model-field/SKILL.md
  2. 164 0
      .claude/skills/draft-release-notes/SKILL.md
  3. 168 0
      .claude/skills/draft-release-notes/audit_milestone.py
  4. 83 0
      .claude/skills/draft-release-notes/render_release_notes.py
  5. 283 0
      .claude/skills/prepare-release/SKILL.md
  6. 164 0
      .claude/skills/publish-release/SKILL.md
  7. 70 0
      .claude/skills/publish-release/pypi-recovery.md
  8. 1 1
      .claude/skills/remove-model-field/SKILL.md
  9. 7 0
      .github/VOUCHED.td
  10. 1 1
      .github/workflows/ci.yml
  11. 30 0
      .github/workflows/enforce-issue-labels.yml
  12. 0 21
      .github/workflows/no-blank-issue.yml
  13. 1 1
      .pre-commit-config.yaml
  14. 1 0
      AGENTS.md
  15. 1 1
      CHANGELOG.md
  16. 2 2
      CONTRIBUTING.md
  17. 18 11
      README.md
  18. 3 2
      base_requirements.txt
  19. 1 1
      contrib/netbox-rq.service
  20. 1 1
      contrib/netbox.service
  21. 46 25
      contrib/openapi.json
  22. 4 7
      docs/development/application-registry.md
  23. 3 0
      docs/development/release-checklist.md
  24. 42 1
      docs/features/context-data.md
  25. 57 15
      docs/index.md
  26. 1 1
      docs/installation/4a-gunicorn.md
  27. 1 1
      docs/installation/4b-uwsgi.md
  28. BIN
      docs/media/misc/reference_architecture.png
  29. 1 1
      docs/models/dcim/module.md
  30. 1 1
      docs/models/extras/configcontext.md
  31. 25 0
      docs/plugins/development/background-jobs.md
  32. 13 0
      docs/plugins/development/filtersets.md
  33. 20 0
      docs/plugins/development/views.md
  34. 40 0
      docs/release-notes/version-4.7.md
  35. 1 1
      mkdocs.yml
  36. 27 0
      netbox/account/tests.py
  37. 4 0
      netbox/account/views.py
  38. 34 0
      netbox/circuits/migrations/0061_circuitterminations_cached_ancestors_on_delete.py
  39. 1 1
      netbox/circuits/migrations/0062_generated_fields.py
  40. 11 2
      netbox/circuits/models/circuits.py
  41. 66 1
      netbox/circuits/tests/test_models.py
  42. 0 1
      netbox/core/filtersets.py
  43. 1 1
      netbox/core/models/data.py
  44. 46 25
      netbox/core/models/jobs.py
  45. 1 1
      netbox/core/tables/template_code.py
  46. 4 2
      netbox/core/tests/test_api.py
  47. 14 1
      netbox/core/tests/test_filtersets.py
  48. 38 2
      netbox/core/tests/test_jobs.py
  49. 169 2
      netbox/core/tests/test_models.py
  50. 126 0
      netbox/core/tests/test_utils.py
  51. 256 3
      netbox/core/tests/test_views.py
  52. 125 2
      netbox/core/utils.py
  53. 14 11
      netbox/dcim/api/serializers_/devices.py
  54. 38 16
      netbox/dcim/api/views.py
  55. 28 0
      netbox/dcim/cable_profiles.py
  56. 6 0
      netbox/dcim/choices.py
  57. 15 13
      netbox/dcim/filtersets.py
  58. 22 13
      netbox/dcim/forms/common.py
  59. 8 3
      netbox/dcim/forms/mixins.py
  60. 39 0
      netbox/dcim/migrations/0245_consolidate_unique_constraints.py
  61. 3 0
      netbox/dcim/models/cables.py
  62. 1 1
      netbox/dcim/models/device_component_templates.py
  63. 31 0
      netbox/dcim/models/device_components.py
  64. 12 7
      netbox/dcim/models/devices.py
  65. 24 11
      netbox/dcim/models/module_moves.py
  66. 20 4
      netbox/dcim/models/modules.py
  67. 12 0
      netbox/dcim/models/sites.py
  68. 5 1
      netbox/dcim/signals.py
  69. 2 2
      netbox/dcim/tests/query_counts.json
  70. 412 29
      netbox/dcim/tests/test_api.py
  71. 51 0
      netbox/dcim/tests/test_cablepaths.py
  72. 130 3
      netbox/dcim/tests/test_channelization.py
  73. 92 0
      netbox/dcim/tests/test_filtersets.py
  74. 133 0
      netbox/dcim/tests/test_forms.py
  75. 37 0
      netbox/dcim/tests/test_models.py
  76. 38 1
      netbox/dcim/tests/test_module_moves.py
  77. 73 0
      netbox/dcim/tests/test_port_mappings.py
  78. 257 4
      netbox/dcim/tests/test_views.py
  79. 25 1
      netbox/dcim/tests/utils.py
  80. 33 13
      netbox/dcim/utils.py
  81. 9 9
      netbox/dcim/views.py
  82. 1 1
      netbox/extras/apps.py
  83. 2 1
      netbox/extras/filtersets.py
  84. 7 0
      netbox/extras/jobs.py
  85. 2 1
      netbox/extras/models/mixins.py
  86. 4 0
      netbox/extras/models/models.py
  87. 1 1
      netbox/extras/tests/query_counts.json
  88. 3 5
      netbox/extras/tests/test_api.py
  89. 9 0
      netbox/extras/tests/test_filtersets.py
  90. 33 1
      netbox/extras/tests/test_models.py
  91. 99 2
      netbox/extras/tests/test_scripts.py
  92. 107 2
      netbox/extras/tests/test_views.py
  93. 1 1
      netbox/ipam/api/views.py
  94. 22 7
      netbox/ipam/filtersets.py
  95. 62 0
      netbox/ipam/tests/test_api.py
  96. 87 0
      netbox/ipam/tests/test_filtersets.py
  97. 49 5
      netbox/ipam/tests/test_views.py
  98. 4 9
      netbox/ipam/views.py
  99. 57 0
      netbox/netbox/api/filters.py
  100. 1 1
      netbox/netbox/api/viewsets/__init__.py

+ 1 - 1
.claude/skills/add-model-field/SKILL.md

@@ -406,5 +406,5 @@ Add the new field to the model's documentation page. Include:
 - Real example (adding a JSONField): `git show 5f802bb18` — adds `choice_colors` to CustomFieldChoiceSet across model, forms, filterset, serializer, GraphQL, and tests
 - Panel attrs reference: `netbox/netbox/ui/attrs.py`
 - Panel classes: `netbox/<app>/ui/panels.py`
-- Base filterset classes: `netbox/netbox/filtersets.py`
+- Base filterset classes: `netbox/netbox/filtersets/__init__.py`
 - Contributing guide: `docs/development/extending-models.md`

+ 164 - 0
.claude/skills/draft-release-notes/SKILL.md

@@ -0,0 +1,164 @@
+---
+name: draft-release-notes
+description: Draft the release notes for a NetBox core (this repository, not a plugin) patch release from its GitHub milestone, audit the commits since the previous release for noteworthy changes missing from the milestone, and render the notes as the release PR or GitHub release body. Use when the user asks to draft, preview, or update the release notes or changelog for an upcoming NetBox release, to check a release milestone against the commit log, or when the prepare-release skill reaches its release notes step.
+---
+
+# Draft NetBox Release Notes
+
+Each patch release has a GitHub milestone named for it (e.g. `v4.7.5`), and every issue closed as completed must carry one (`.github/workflows/enforce-milestone.yml` reopens any that don't). The milestone is therefore the starting point for the release notes. But it only holds the issues someone assigned to it, so this skill also audits every commit since the previous release for changes the milestone misses. It then turns the issues into a section of `docs/release-notes/version-X.Y.md`.
+
+The notes are published text. Present the draft to the user for review and editing; never commit it unreviewed.
+
+## 1. Gather the Milestone
+
+```bash
+gh issue list --milestone vX.Y.Z --state all --limit 200 \
+  --json number,title,state,stateReason,labels,url
+```
+
+- Warn about any issue still open. The release should not proceed until it is closed or moved to another milestone.
+- Skip issues closed as `NOT_PLANNED` or `DUPLICATE`; only `COMPLETED` issues are candidates.
+
+## 2. Audit the Commits Against the Milestone
+
+A change whose issue was never assigned to the milestone, or was assigned to the wrong one, is invisible to step 1, even though it shipped. Check every commit since the previous release:
+
+```bash
+git fetch origin main --tags
+python3 .claude/skills/draft-release-notes/audit_milestone.py vX.Y.Z
+```
+
+The script follows each first-parent commit on `main` since the previous release tag to its PR, and to the issues that the PR closes or the commit subject references (`Fixes #N`). It skips release commits, translation updates, and updates to the list of vouched contributors (`.github/VOUCHED.td`), and prints two tables:
+
+- **Reverts, and commits not linked to the milestone**, with each linked issue's milestone and `type:` label, and the areas of the codebase changed (`app`, `dependencies`, `install`, `schema`, `docs`, `tests`, `tooling`). Reverts are always listed, even when their issue is in the milestone. A commit marked "not on GitHub" hasn't been pushed, so its PR and issues can't be looked up.
+- **Milestone issues not linked to any commit**
+
+To audit a past release, pass `--base vPREV --head vX.Y.Z`.
+
+Review every row; the script only finds the gaps, and deciding what's noteworthy is up to you:
+
+- **Linked issue with no milestone, or a different one**: the change shipped in this release, so the issue belongs in this milestone. Propose moving it (`gh issue edit N --milestone vX.Y.Z`). Once the user confirms, it goes through the remaining steps like any other issue.
+- **No linked issue**: read the PR and the diff (`gh pr view P --json title,body,files`, `git show --stat <sha>`). Changes confined to `tooling`, `tests`, or `docs` are rarely noteworthy. For any other change, decide whether users would notice it: a bug fix, a change in behavior or in the API, a new feature, a performance change, or a dependency upgrade. If they would, search for an issue the PR should have referenced (`gh search issues --repo netbox-community/netbox "<keywords>"`) and propose assigning it to the milestone. If no issue exists, tell the user; every entry links to an issue, and opening one is up to them.
+- **A commit and its revert** cancel out, and neither belongs in the notes. For example, in v4.7.1, the fix for #23097 (which had no milestone) was reverted before the release.
+- **Milestone issues not linked to any commit**: confirm that each one actually shipped. It may have been fixed by a commit which didn't reference it (check `git log` for related changes), or closed without a code change (e.g. an investigation, such as #23362 in v4.7.4). Or it may have been fixed on `feature` instead (`git log origin/feature --oneline --grep "#N"`), in which case it belongs in the next minor release's milestone, and not in these notes.
+
+Report a verdict for every row: noteworthy and how to handle it, or not noteworthy and why. Make no milestone changes without the user's confirmation.
+
+## 3. Sort Into Sections
+
+Assign each issue by its `type:` label, then sort each section by ascending issue number:
+
+| Label | Section |
+|---|---|
+| `type: feature` | `### Enhancements` |
+| `type: performance` | `### Performance Improvements` |
+| `type: bug` | `### Bug Fixes` |
+| `type: housekeeping`, `type: documentation`, `type: translation` | Omitted |
+| `type: deprecation`, `type: removal`, or no `type:` label | Flag for the user |
+
+Omit any section that ends up empty.
+
+## 4. Write Each Entry
+
+For each included issue, read the issue body and the PR(s) that closed it:
+
+```bash
+gh issue view N --json title,body,closedByPullRequestsReferences
+gh pr view P --json title,body,files
+```
+
+Use parallel subagents to read the issues when there are more than a dozen or so, asking each one for a proposed entry plus a one-line note on what changed.
+
+Each entry is one line:
+
+```markdown
+* [#N](https://github.com/netbox-community/netbox/issues/N) - <description>
+```
+
+Write the description for the end user, not from the issue title or the code:
+
+- Describe the effect of the change, so that it makes sense without reading the issue.
+- Start with a verb: Add, Fix, Prevent, Restrict, Support, Enable, Correct, Avoid, Display.
+- Use "Fix exception when ..." for crashes and HTTP 500 errors.
+- Include terms users will search for: exception names, field and parameter names in backticks, "REST API", "GraphQL", the UI view involved.
+- No trailing period.
+
+Examples from v4.7.4 (issue title → entry):
+
+| Issue title | Entry |
+|---|---|
+| Add device from location page has wrong argument | Pre-populate the location when adding a non-racked device from a location's view |
+| Every non-bulk change enqueues its own Search cache update job instead of coalescing — RQ queue flood | Coalesce search cache updates rather than enqueuing a background job for each change |
+| Bookmark, journal entry and related API endpoints fail for objects without a REST serializer | Fix exception when listing bookmarks, journal entries, and other objects assigned to objects which have no REST API serializer |
+| Please add several more basic widely used trunk cable profiles ... | Add 8C1P, 12C1P, and 24C1P trunk cable profiles |
+
+## 5. Flag Judgment Calls
+
+Recommend omitting, but let the user decide:
+
+- Changes with no user-visible effect, such as internal helpers, tests, or CI (e.g. #23432, a TypeScript type guard fix, was left out of v4.7.4).
+- Dependency upgrades filed as features (e.g. #23383, an upgrade of `strawberry-graphql-django`).
+- Bugs introduced and fixed within the same release cycle, which no user ever saw.
+
+Present every issue in a table grouped as **included**, **omitted**, and **needs your decision**, with a short reason for each omission.
+
+## 6. Add Warnings
+
+Place a warning above the first section for anything a user or operator must act on: an API or GraphQL change they need to adapt to, a dependency upgrade requiring action (the prepare-release skill passes these in), or a new minimum version. State what to do, concretely:
+
+```markdown
+!!! warning "GraphQL Circuit Status Values"
+    The GraphQL enum for circuit and virtual circuit status ... must be updated to use the prefixed form (e.g. `{exact: STATUS_ACTIVE}`). See [#23469](https://github.com/netbox-community/netbox/issues/23469) for details.
+```
+
+Use absolute URLs for links; relative links to other docs pages break in the PR and release bodies.
+
+## 7. Output
+
+The section goes at the top of `docs/release-notes/version-X.Y.md`, directly below the `# NetBox vX.Y` heading, and is separated from the previous release by `---`:
+
+```markdown
+# NetBox vX.Y
+
+## vX.Y.Z (YYYY-MM-DD)
+
+!!! warning "..."
+    ...
+
+### Enhancements
+
+* ...
+
+### Bug Fixes
+
+* ...
+
+---
+
+## vX.Y.(Z-1) (...)
+```
+
+The date is the planned release date (the milestone's due date, and `published` in `netbox/release.yaml`).
+
+## Rendering the PR and Release Bodies
+
+Once the section is in the docs file, render the GitHub bodies from it rather than by hand:
+
+```bash
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format pr       # release PR body
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format release  # GitHub release body
+```
+
+The script drops the `## vX.Y.Z` heading and converts admonitions to GitHub alerts (`> [!WARNING]`, dropping the title). The `release` format also promotes `###` headings to `##`, as the release checklist specifies.
+
+## Why These Choices
+
+- **Start from the milestone, then audit the commits.** The milestone provides each change's issue, type label, and context, which commit subjects often lack (e.g. `fix(core): ...`). But the milestone workflow only catches issues closed with no milestone, not issues given the wrong one, or never linked to the fix at all. Only the commit log shows everything that shipped.
+- **The audit is scripted, the verdicts aren't.** Mapping some 50 commits to PRs and issues takes one batched GraphQL query in the script, but well over a hundred separate `gh` calls by hand. Whether a change is noteworthy depends on reading it.
+- **Entries are rewritten, not copied.** Issue titles describe the reporter's symptom; the notes must describe the change for someone who never saw the issue.
+- **Rendering is scripted.** The docs file is the single source of truth, so the PR body and the release body (often created in a later session) can't drift from it or from each other.
+
+## References
+
+- [`docs/development/release-checklist.md`](../../../docs/development/release-checklist.md): Update Version and Changelog
+- [`docs/release-notes/`](../../../docs/release-notes/): Past release notes, for style

+ 168 - 0
.claude/skills/draft-release-notes/audit_milestone.py

@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""List the commits since the previous release which aren't linked to an issue in the release's milestone."""
+
+import argparse
+import json
+import re
+import subprocess
+
+OWNER, NAME = 'netbox-community', 'netbox'
+BATCH_SIZE = 50
+ISSUE_REF = re.compile(r'\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):? #(\d+)', re.IGNORECASE)
+ISSUE_FIELDS = 'number title milestone { title } labels(first: 20) { nodes { name } }'
+
+
+def run(*args):
+    return subprocess.run(args, capture_output=True, text=True, check=True).stdout
+
+
+def graphql(fields):
+    """Query the repository. Fields which can't be resolved (e.g. an issue number which is a PR) return None."""
+    query = f'{{ repository(owner: "{OWNER}", name: "{NAME}") {{ {" ".join(fields)} }} }}'
+    result = subprocess.run(['gh', 'api', 'graphql', '-f', f'query={query}'], capture_output=True, text=True)
+    try:
+        response = json.loads(result.stdout)
+    except json.JSONDecodeError:
+        raise SystemExit(result.stderr) from None
+    if not response.get('data') or any(error.get('type') != 'NOT_FOUND' for error in response.get('errors', [])):
+        raise SystemExit(result.stderr)
+    return response['data']['repository']
+
+
+def get_commits(base, head):
+    """Return the SHA, subject, and changed paths (relative to the first parent) of each first-parent commit."""
+    log = run(
+        'git', 'log', '--first-parent', '--diff-merges=first-parent', '--reverse', '--name-only',
+        '--format=%x00%H%x09%s', f'{base}..{head}',
+    )
+    commits = []
+    for entry in log.split('\0')[1:]:
+        header, *paths = entry.splitlines()
+        sha, subject = header.split('\t', 1)
+        commits.append((sha, subject, [path for path in paths if path]))
+    return commits
+
+
+def get_areas(paths):
+    """Return the areas of the codebase to which the given paths belong."""
+    areas = set()
+    for path in paths:
+        if path.startswith('netbox/translations/'):
+            areas.add('translations')
+        elif '/tests/' in path:
+            areas.add('tests')
+        elif path.startswith('netbox/'):
+            areas.add('app')
+        elif path in ('base_requirements.txt', 'requirements.txt', 'pyproject.toml'):
+            areas.add('dependencies')
+        elif path in ('contrib/openapi.json', 'contrib/generated_schema.json'):
+            areas.add('schema')
+        elif path == 'upgrade.sh' or path.startswith('contrib/'):
+            areas.add('install')
+        elif path.startswith('docs/') or path in ('mkdocs.yml', 'README.md'):
+            areas.add('docs')
+        else:
+            areas.add('tooling')
+    return areas
+
+
+def get_links(commits):
+    """Map each commit to its pull requests and to the issues it references or its PRs close."""
+    links = {}
+    for i in range(0, len(commits), BATCH_SIZE):
+        batch = commits[i:i + BATCH_SIZE]
+        data = graphql(
+            f'c{j}: object(oid: "{sha}") {{ ... on Commit {{ associatedPullRequests(first: 3) {{ nodes {{ '
+            f'number closingIssuesReferences(first: 10) {{ nodes {{ {ISSUE_FIELDS} }} }} }} }} }} }}'
+            for j, (sha, _, _) in enumerate(batch)
+        )
+        for j, (sha, _, _) in enumerate(batch):
+            # A commit which hasn't been pushed resolves to None
+            if (commit := data[f'c{j}']) is None:
+                links[sha] = {'prs': None, 'issues': {}}
+                continue
+            prs = commit['associatedPullRequests']['nodes']
+            issues = {issue['number']: issue for pr in prs for issue in pr['closingIssuesReferences']['nodes']}
+            links[sha] = {'prs': [pr['number'] for pr in prs], 'issues': issues}
+
+    # Issues referenced only in a commit subject (e.g. "Fixes #123: ...")
+    missing = {
+        int(number) for sha, subject, _ in commits
+        for number in ISSUE_REF.findall(subject) if int(number) not in links[sha]['issues']
+    }
+    if missing:
+        data = graphql(f'i{number}: issue(number: {number}) {{ {ISSUE_FIELDS} }}' for number in missing)
+        for sha, subject, _ in commits:
+            for number in map(int, ISSUE_REF.findall(subject)):
+                if issue := data.get(f'i{number}'):
+                    links[sha]['issues'].setdefault(number, issue)
+    return links
+
+
+def escape(text):
+    """Escape pipes for use in a Markdown table cell."""
+    return text.replace('|', r'\|')
+
+
+def describe_issue(issue):
+    milestone = issue['milestone']['title'] if issue['milestone'] else 'no milestone'
+    types = [label['name'] for label in issue['labels']['nodes'] if label['name'].startswith('type:')]
+    return f"#{issue['number']} ({', '.join([milestone, *types])})"
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('milestone', help='Release milestone, e.g. v4.7.5')
+    parser.add_argument('--head', default='origin/main', help='Last commit of the release (default: origin/main)')
+    parser.add_argument('--base', help='Previous release tag (default: the latest tag before --head)')
+    args = parser.parse_args()
+
+    base = args.base or run('git', 'describe', '--tags', '--abbrev=0', f'{args.head}^').strip()
+    commits = get_commits(base, args.head)
+    links = get_links(commits)
+
+    uncovered, routine, linked_issues = [], 0, set()
+    for sha, subject, paths in commits:
+        issues = links[sha]['issues']
+        linked_issues.update(issues)
+        areas = get_areas(paths)
+        in_milestone = any(
+            issue['milestone'] and issue['milestone']['title'] == args.milestone for issue in issues.values()
+        )
+        if subject.startswith('Release v') or areas == {'translations'} or set(paths) == {'.github/VOUCHED.td'}:
+            routine += 1
+        # Always list reverts, which cancel out the commit they revert
+        elif subject.startswith('Revert ') or not in_milestone:
+            uncovered.append((sha, subject, links[sha]['prs'], issues, areas))
+
+    print(f'# Milestone audit: {args.milestone} ({base}..{args.head})\n')
+    print(
+        f'{len(commits)} commits: {len(commits) - len(uncovered) - routine} linked to the milestone, '
+        f'{routine} routine (releases, translations, vouched contributors), '
+        f'{len(uncovered)} reverts or not linked to the milestone.\n'
+    )
+    print('## Reverts, and commits not linked to the milestone\n')
+    print('| Commit | Subject | PRs | Linked issues | Areas changed |')
+    print('|---|---|---|---|---|')
+    for sha, subject, prs, issues, areas in uncovered:
+        prs = 'not on GitHub' if prs is None else ', '.join(f'#{pr}' for pr in prs) or '-'
+        issues = ', '.join(map(describe_issue, issues.values())) or '-'
+        print(f"| {sha[:9]} | {escape(subject)} | {prs} | {issues} | {', '.join(sorted(areas))} |")
+
+    milestone_issues = json.loads(run(
+        'gh', 'issue', 'list', '--repo', f'{OWNER}/{NAME}', '--milestone', args.milestone,
+        '--state', 'closed', '--limit', '500', '--json', 'number,title,stateReason',
+    ))
+    unmatched = [
+        issue for issue in milestone_issues
+        if issue['stateReason'] == 'COMPLETED' and issue['number'] not in linked_issues
+    ]
+    print('\n## Milestone issues not linked to any commit\n')
+    print('| Issue | Title |')
+    print('|---|---|')
+    for issue in sorted(unmatched, key=lambda issue: issue['number']):
+        print(f"| #{issue['number']} | {escape(issue['title'])} |")
+
+
+if __name__ == '__main__':
+    main()

+ 83 - 0
.claude/skills/draft-release-notes/render_release_notes.py

@@ -0,0 +1,83 @@
+#!/usr/bin/env python3
+"""Render a release's notes from docs/release-notes/ as the body of its GitHub PR or release."""
+
+import argparse
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[3]
+
+# MkDocs admonition type (including Material's aliases) -> GitHub alert type
+ALERTS = {
+    'attention': 'WARNING',
+    'bug': 'CAUTION',
+    'caution': 'WARNING',
+    'danger': 'CAUTION',
+    'error': 'CAUTION',
+    'failure': 'CAUTION',
+    'hint': 'TIP',
+    'important': 'IMPORTANT',
+    'info': 'NOTE',
+    'note': 'NOTE',
+    'tip': 'TIP',
+    'warning': 'WARNING',
+}
+
+
+def extract_section(text, version):
+    """Return the lines of the section for the given version, excluding its heading."""
+    lines = text.splitlines()
+    heading = re.compile(rf'^## v{re.escape(version)} \(')
+    start = next((i for i, line in enumerate(lines) if heading.match(line)), None)
+    if start is None:
+        raise SystemExit(f'No section for v{version} found')
+    section = []
+    for line in lines[start + 1:]:
+        if line == '---' or line.startswith('## '):
+            break
+        section.append(line)
+    return section
+
+
+def convert_admonitions(lines):
+    """Convert MkDocs admonitions to GitHub alerts. GitHub alerts have no title, so it is dropped."""
+    output = []
+    i = 0
+    while i < len(lines):
+        if not (match := re.match(r'^!!! (\w+)', lines[i])):
+            output.append(lines[i])
+            i += 1
+            continue
+        output.append(f'> [!{ALERTS.get(match[1], "NOTE")}]')
+        i += 1
+        # The body is every following line indented four spaces, including blank lines between paragraphs
+        end = i
+        while end < len(lines) and (lines[end].startswith('    ') or not lines[end].strip()):
+            end += 1
+        while end > i and not lines[end - 1].strip():
+            end -= 1
+        output.extend(f'> {line[4:]}' if line.strip() else '>' for line in lines[i:end])
+        i = end
+    return output
+
+
+def main():
+    parser = argparse.ArgumentParser(description=__doc__)
+    parser.add_argument('version', help='Release version, e.g. 4.7.5')
+    parser.add_argument('--format', choices=('pr', 'release'), default='pr')
+    args = parser.parse_args()
+
+    version = args.version.removeprefix('v')
+    major, minor = version.split('.')[:2]
+    text = (ROOT / 'docs' / 'release-notes' / f'version-{major}.{minor}.md').read_text()
+
+    lines = convert_admonitions(extract_section(text, version))
+    if args.format == 'release':
+        # Promote each heading one level (### -> ##)
+        lines = [re.sub(r'^#(#+ )', r'\1', line) for line in lines]
+
+    print('\n'.join(lines).strip())
+
+
+if __name__ == '__main__':
+    main()

+ 283 - 0
.claude/skills/prepare-release/SKILL.md

@@ -0,0 +1,283 @@
+---
+name: prepare-release
+description: Prepare a NetBox core (this repository, not a plugin) patch release vX.Y.Z up to an open release PR — upgrade Python, UI, and dev dependencies, pull translations, bump release.yaml, draft the release notes, regenerate the schemas, validate, and open the "Release vX.Y.Z" pull request. Use when a maintainer asks to prepare, cut, or start a NetBox release. Publishing after the PR merges is the publish-release skill.
+---
+
+# Prepare a NetBox Patch Release
+
+This skill automates the [Patch Releases](../../../docs/development/release-checklist.md#patch-releases) section of the release checklist, up to opening the release PR. The checklist remains the authoritative process; if the two disagree, follow the checklist and point out the discrepancy.
+
+**Ground rules**
+
+- **The release PR contains only release changes.** Never fix application code, tests, or lint on the release branch. If an upgrade breaks something, hold it back (step 3) and report it; the fix belongs in a separate PR merged before the release.
+- **Confirm before anything outward-facing**: pushing, opening the PR, or editing milestones and issues.
+- **Report problems early.** Each step lists what to check; stop and ask whenever something unexpected turns up rather than working around it.
+- Run all commands from the repository root, with the NetBox venv active, unless stated otherwise. Write temporary files to the session scratchpad (`$SCRATCH` below), not `/tmp`.
+
+## 0. Inputs
+
+- **Version**: default to a patch bump of `version` in `netbox/release.yaml`. Confirm that an open milestone with the same name exists:
+
+    ```bash
+    gh api 'repos/netbox-community/netbox/milestones?state=open&per_page=100' \
+      --jq '.[] | select(.title == "vX.Y.Z") | {number, due_on, open_issues, closed_issues}'
+    ```
+
+- **Release date**: default to the milestone's `due_on` date.
+
+Confirm both with the user before continuing.
+
+**This skill covers patch releases only.** If the version is `X.Y.0`, or carries a designation such as `beta1` or `rc1`, stop: minor and pre-releases need the manual steps in the checklist's Minor Version Releases section (new install, upgrade path tests, merging `feature`) and target different branches.
+
+## 1. Preflight
+
+Run every check, then report all failures together. All are read-only.
+
+```bash
+git fetch origin --tags
+git status --porcelain                                  # must be empty
+git rev-parse --abbrev-ref HEAD                         # must be main
+git rev-parse main origin/main                          # must match
+git remote get-url origin                               # must be netbox-community/netbox
+gh repo set-default --view                              # must be netbox-community/netbox
+gh api repos/netbox-community/netbox --jq .permissions.push   # must be true
+gh api orgs/netboxlabs/members/$(gh api user -q .login) --silent   # must succeed
+gh run list --workflow ci.yml --branch main --limit 3 --json headSha,status,conclusion
+pgrep -af '[m]anage.py test'                            # must find no other test run
+```
+
+- **gh default repository**: the `gh` commands in this skill and in `draft-release-notes` rely on it. If it's unset or points at a fork, ask the user to run `gh repo set-default netbox-community/netbox`.
+- **CI**: the most recent completed run on `main` must have succeeded. CI skips commits that touch only translations, `contrib/`, or issue templates, so HEAD may have no run of its own; if so, confirm with `git diff --stat <run headSha>..origin/main` that only such paths changed since.
+- **Milestone**: `open_issues` must be 0. Otherwise list the open issues and ask whether to wait or move them to the next milestone (`gh issue edit N --milestone vX.Y.(Z+1)`, only after confirmation).
+- **Test runs**: the brackets in the `pgrep` pattern stop it from matching the shell that runs it. Without them, it always finds a match.
+- **Maintainer status**: a release PR has no linked issue. `AGENTS.md` waives that requirement only for maintainers (members of the `netboxlabs` GitHub organization), which the membership check confirms. Push permission is needed as well, to push the release branch.
+- **Tools**: `tx` with a token (`~/.transifexrc` or `$TX_TOKEN`), `msgfmt` (gettext), `zensical`, and the UI package manager matching the lockfile in `netbox/project-static/` (`yarn.lock` → yarn 1; `package-lock.json` → npm). If `netbox/project-static/.nvmrc` exists, `node --version` must satisfy it.
+
+## 2. Create the Release Branch
+
+```bash
+git checkout -B release-vX.Y.Z main
+```
+
+## 3. Update Python Dependencies
+
+`base_requirements.txt` holds the loose constraints, plus a comment above each package with its changelog URL. `requirements.txt` holds the exact pins that ship.
+
+1. Upgrade the active venv to the latest allowed versions (this is the checklist's procedure, and it modifies the developer's venv):
+
+    ```bash
+    pip install -U -r base_requirements.txt
+    pip check
+    ```
+
+2. Rewrite the pins from the installed versions, keeping the existing `package[extras]==version` format:
+
+    ```bash
+    python - <<'PY'
+    import re
+    from importlib.metadata import version
+    from pathlib import Path
+
+    path = Path('requirements.txt')
+    lines = []
+    for line in path.read_text().splitlines():
+        name, extras = re.match(r'^([A-Za-z0-9_.-]+)(\[[^\]]*\])?==', line).groups()
+        lines.append(f'{name}{extras or ""}=={version(name)}')
+    path.write_text('\n'.join(lines) + '\n')
+    PY
+    git diff requirements.txt
+    ```
+
+3. Review the changelog of every upgraded package across the full version range, using the URL from its comment in `base_requirements.txt`. Use parallel subagents when there are more than a few. Classify each upgrade:
+    - **Breaking**: affects NetBox or its users (removed APIs, changed defaults, new minimum versions of Python/PostgreSQL/Django).
+    - **Needs a release note warning**: users or operators must act (e.g. the sorl-thumbnail 13.4.0 upgrade in v4.7.4 required `manage.py thumbnail clear` for some storage setups).
+    - **Routine**.
+4. If an upgrade breaks something (here, or in the tests in step 10), hold it back: constrain it in `base_requirements.txt` with a comment explaining why (and linking the upstream issue, if there is one), reinstall the previous version, and restore its pin. Report every hold to the user. If step 8 has already run, run it again, so that the schemas match the versions that ship.
+5. Verify the pins match the constraints:
+
+    ```bash
+    python3 scripts/verify_dependencies.py
+    ```
+
+## 4. Update UI Dependencies
+
+Work in `netbox/project-static/`. Pick the commands by lockfile.
+
+**npm** (`package-lock.json`):
+
+```bash
+npm ci
+npm run upgrade    # upgrades all but the held dependencies, and explains what remains behind
+npm run check
+npm run build
+```
+
+**yarn** (`yarn.lock`):
+
+```bash
+yarn install --frozen-lockfile
+yarn outdated
+```
+
+Upgrade patch and minor updates, keeping each entry's existing version style in `package.json`. `yarn upgrade` writes exactly the version spec it's given:
+
+- Under `dependencies` (exact pins): `yarn upgrade <package>@<version>`
+- Under `devDependencies` (caret ranges): `yarn upgrade '<package>@^<version>'`
+
+Ask the user about each major upgrade, and about any minor upgrade of `@tabler/core` or `bootstrap`, which take patch releases only. Then:
+
+```bash
+yarn validate
+yarn bundle
+```
+
+**Either way**, stage the rebuilt bundles, then run `scripts/verify-bundles.sh` from the repository root:
+
+```bash
+git add netbox/project-static/dist/
+scripts/verify-bundles.sh
+git diff --cached --stat netbox/project-static/dist/
+```
+
+The script rebuilds the bundles and fails if the result differs from the files in the index. Unstaged changes in `dist/` would make it fail even when the build is fine, which is why the bundles are staged first. The last command shows the change in each bundle's size (the bundles are marked binary in `.gitattributes`, so the diffstat reports bytes). Flag any large change for the user.
+
+## 5. Update Translations
+
+1. Ask the user to confirm that they have checked the [Transifex dashboard](https://app.transifex.com/netbox-community/netbox/dashboard/) and machine-translated any language not marked _ready for use_ (see [translations.md](../../../docs/development/translations.md#updating-translated-strings)). This step can't be automated.
+2. Pull and compile:
+
+    ```bash
+    tx pull --force
+    (cd netbox && python manage.py compilemessages)
+    ```
+
+3. Confirm that only translation files changed; this should print nothing:
+
+    ```bash
+    git status --porcelain netbox/translations | grep -vE 'LC_MESSAGES/django\.(po|mo)$'
+    ```
+
+## 6. Bump the Version
+
+Set `version` and `published` in `netbox/release.yaml`:
+
+```yaml
+version: "X.Y.Z"
+edition: "Community"
+published: "YYYY-MM-DD"
+```
+
+Do this before step 8: the OpenAPI schema embeds the version. Don't edit `pyproject.toml`; the package version is derived from `release.yaml`.
+
+## 7. Write the Release Notes
+
+Follow the `draft-release-notes` skill, passing it the upgrades that need a warning from step 3. Its commit audit checks every commit since the previous release for noteworthy changes that the milestone misses, and may propose assigning issues to the milestone (confirm each change with the user). Insert the section at the top of `docs/release-notes/version-X.Y.md`.
+
+Then stop and ask the user to review and edit the notes. They are published under the user's name, so they should be in the user's own words (see `AI_POLICY.md`).
+
+## 8. Regenerate the Schemas
+
+Device type definition schema:
+
+```bash
+(cd netbox && python manage.py buildschema --write)
+```
+
+OpenAPI schema, generated with all plugins disabled. Use a configuration module derived from the testing configuration, so that the output doesn't depend on the local `configuration.py`:
+
+```bash
+printf 'from netbox.configuration_testing import *  # noqa: F403\n\nPLUGINS = []\n' > $SCRATCH/release_schema_config.py
+(cd netbox && PYTHONPATH=$SCRATCH NETBOX_CONFIGURATION=release_schema_config \
+  python manage.py spectacular --format openapi-json > $SCRATCH/openapi.json) \
+  && test -s $SCRATCH/openapi.json && cp $SCRATCH/openapi.json contrib/openapi.json
+```
+
+The schema is written to the scratchpad first, so a failed run can't leave `contrib/openapi.json` empty. If the command fails, stop and report the error.
+
+Check the result:
+
+- `grep -c '"/api/plugins/' contrib/openapi.json` prints `0`.
+- `info.version` in `contrib/openapi.json` is `X.Y.Z`.
+- Every other change in `git diff contrib/openapi.json` corresponds to an API change merged this cycle. Flag any that doesn't, because it suggests an unexpected effect of a dependency upgrade.
+
+## 9. Update Development Dependencies
+
+Ruff is pinned in three places, which must agree:
+
+- `.pre-commit-config.yaml`: `rev: vX` under `ruff-pre-commit`
+- `.github/workflows/ci.yml`: `version: "X"` under `ruff-action`
+- `pyproject.toml`: `ruff==X` in the `dev` group
+
+Look up the latest release (`curl -s https://pypi.org/pypi/ruff/json | python3 -c 'import json,sys; print(json.load(sys.stdin)["info"]["version"])'`). If it's newer, update all three pins, then `pip install ruff==X` and run `ruff check`. If the new version reports any violations, revert the three pins and tell the user: fixing them is cleanup, which belongs in its own PR.
+
+## 10. Validate
+
+1. Run the full test suite with the `run-tests` skill. Check `pgrep -af '[m]anage.py test'` immediately beforehand, and run it in the background. On a failure, first rule out the test harness: re-run the failing tests without `--parallel`, against a freshly built database (`--noinput`, without `--keepdb`). Then find the responsible upgrade, by reinstalling previous versions one at a time, and hold it back (step 3).
+
+    After holding back any upgrade, rebuild whatever was generated with it before continuing. For a Python package, re-run step 8 (the schemas). For a UI package, restore its previous version in `package.json` and the lockfile, then rebuild, stage, and verify the bundles as at the end of step 4. Then re-run the tests.
+2. Run the remaining checks:
+
+    ```bash
+    (cd netbox && python manage.py check && python manage.py makemigrations --check --dry-run)
+    ruff check
+    zensical build
+    python3 scripts/verify_dependencies.py
+    ```
+
+    A dependency upgrade can generate new migrations; treat any as a breaking upgrade.
+3. Start the development server (`python manage.py runserver` from `netbox/`, in the background). Confirm that `/login/` returns HTTP 200 and that `/static/netbox.js` is served. Then ask the user to log in and click through the UI, the REST API browser, and GraphiQL before you continue. Stop the server afterward.
+
+## 11. Check the Scope
+
+`git status --porcelain` may list only these paths:
+
+- `base_requirements.txt`, `requirements.txt`
+- `netbox/project-static/package.json`, the lockfile, and `netbox/project-static/dist/`
+- `netbox/translations/*/LC_MESSAGES/django.{po,mo}`
+- `netbox/release.yaml`
+- `docs/release-notes/version-X.Y.md`
+- `contrib/generated_schema.json`, `contrib/openapi.json`
+- `.pre-commit-config.yaml`, `.github/workflows/ci.yml`, `pyproject.toml` (ruff pins only)
+
+A dependency upgrade sometimes forces a change to the documentation build configuration (e.g. v4.7.4 changed `mkdocs.yml` after `mkdocs-material` was dropped). Such a change needs the user's explicit sign-off. For anything else, stop and ask.
+
+## 12. Commit and Open the PR
+
+Confirm with the user before pushing. Then stage files by explicit path (never `git add -A`) and commit with the pre-commit hooks enabled:
+
+```bash
+git add <paths from step 11>
+git commit -m "Release vX.Y.Z"
+git push -u origin release-vX.Y.Z
+
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format pr > $SCRATCH/pr-body.md
+gh pr create --repo netbox-community/netbox --base main --head release-vX.Y.Z \
+  --title "Release vX.Y.Z" --body-file $SCRATCH/pr-body.md
+```
+
+Ask the user who should review the PR, and add them with `gh pr edit --add-reviewer`. Don't add any "generated by" text to the commit or the PR.
+
+## 13. Hand Off
+
+Summarize for the user:
+
+- Upgraded dependencies worth knowing about, and any that were held back (with reasons)
+- Release note judgment calls they made, or still need to make
+- The PR URL
+
+Once CI passes, a colleague approves, and the PR is merged, the next step is the `publish-release` skill.
+
+## Why These Choices
+
+- **Version before schemas.** `spectacular` writes the version from `release.yaml` into `contrib/openapi.json`; generating it first leaves a stale version in the schema.
+- **A plugin-free configuration derived from the testing configuration.** The checklist requires disabling plugins. A fixed configuration makes the output the same for every maintainer, regardless of their local `configuration.py`.
+- **Hold, don't fix.** The checklist forbids functional changes in a release PR so that it can be reviewed quickly. A pin constraint keeps the release moving, and the real fix gets its own review.
+- **The milestone date is the release date.** Each patch milestone's due date is the planned release date, and it becomes `published` in `release.yaml` and the release title.
+
+## References
+
+- [`docs/development/release-checklist.md`](../../../docs/development/release-checklist.md): The authoritative release process
+- [`docs/development/web-ui.md`](../../../docs/development/web-ui.md): UI dependency management
+- [`docs/development/translations.md`](../../../docs/development/translations.md): Transifex setup
+- [`scripts/verify_dependencies.py`](../../../scripts/verify_dependencies.py), [`scripts/verify-bundles.sh`](../../../scripts/verify-bundles.sh): Checks also run by CI
+- `draft-release-notes` and `run-tests` skills

+ 164 - 0
.claude/skills/publish-release/SKILL.md

@@ -0,0 +1,164 @@
+---
+name: publish-release
+description: Publish a NetBox core (this repository, not a plugin) release after its "Release vX.Y.Z" PR has merged — check the publishing prerequisites, create the GitHub release and tag, watch the PyPI publishing workflow, verify and smoke-test the published package, and close out the milestone. Use when a maintainer asks to publish, tag, or ship a NetBox release whose release PR is merged.
+---
+
+# Publish a NetBox Release
+
+This skill automates the checklist's steps from [Confirm Package Publishing Prerequisites](../../../docs/development/release-checklist.md#confirm-package-publishing-prerequisites) onward, after the release PR (prepared with the `prepare-release` skill) has merged. The checklist remains the authoritative process.
+
+Creating the GitHub release pushes the `vX.Y.Z` tag, and the tag push starts `.github/workflows/release.yml`, which publishes to PyPI. **Files uploaded to PyPI can never be replaced**, so check everything before step 3, and confirm with the user before every outward-facing action.
+
+Run commands from the repository root. Write temporary files to the session scratchpad (`$SCRATCH` below).
+
+## 1. Verify the Merge
+
+```bash
+git status --porcelain                                        # must be empty
+git fetch origin --tags
+gh repo set-default --view                                    # must be netbox-community/netbox
+gh pr list --repo netbox-community/netbox --head release-vX.Y.Z --state merged --json number,mergeCommit
+git checkout --detach <merge commit SHA>
+grep '^version:' netbox/release.yaml                          # must be "X.Y.Z"
+git ls-remote --tags origin vX.Y.Z                            # must print nothing
+gh run list --repo netbox-community/netbox --workflow ci.yml --commit <merge commit SHA> --json status,conclusion
+```
+
+- The release PR must be merged. Its merge commit is the **release commit**, and every remaining step uses it rather than `main` HEAD. Automated commits (source translation strings, the VOUCHED list) can land on `main` after the PR merges, and they must not end up in the tag.
+- `netbox/release.yaml` must hold the version being released. The published package version comes from this file, not from the tag.
+- If the tag already exists, stop: the release has been created, at least in part.
+- The CI run for the release commit must have succeeded.
+
+## 2. Check the Publishing Prerequisites
+
+These checks are read-only. **Never change repository or environment settings yourself**; report what's missing and let the user decide.
+
+**The `pypi` environment's approval gate.** The checklist requires the `pypi` environment to have required reviewers with **Prevent self-review** enabled, and deployments restricted to `v*` tags:
+
+```bash
+gh api repos/netbox-community/netbox/environments/pypi \
+  --jq '{rules: [.protection_rules[] | {type, prevent_self_review, reviewers: [.reviewers[]?.reviewer | .login // .slug]}], policy: .deployment_branch_policy}'
+gh api repos/netbox-community/netbox/environments/pypi/deployment-branch-policies \
+  --jq '[.branch_policies[] | {name, type}]'
+```
+
+Expect a `required_reviewers` rule with `prevent_self_review: true` and at least one reviewer, a policy with `custom_branch_policies: true`, and a `{"name": "v*", "type": "tag"}` branch policy. (The second command returns 404 when no custom policy is configured.)
+
+**If any of these is missing, stop.** Explain that creating the release will publish to PyPI immediately, with no approval step, and ask whether to fix the environment first (under Settings → Environments → `pypi`) or to proceed regardless.
+
+**The PyPI trusted publisher.** Its configuration on PyPI can't be queried. Treat it as confirmed if the previous release's publish job succeeded and the workflow's publish jobs haven't changed since:
+
+```bash
+PREV=$(git describe --tags --abbrev=0 HEAD^)
+gh run list --workflow release.yml --event push --branch $PREV --json databaseId,conclusion,url
+git diff $PREV..HEAD -- .github/workflows/release.yml
+```
+
+Otherwise, ask the user to confirm that the trusted publisher on PyPI still matches this repository, `release.yml`, and the `pypi` environment name.
+
+## 3. Create the Release
+
+Render the release body from the merged release notes:
+
+```bash
+python3 .claude/skills/draft-release-notes/render_release_notes.py X.Y.Z --format release > $SCRATCH/release-body.md
+```
+
+Show the user the title and body, and state plainly that creating the release pushes the tag and starts publishing to PyPI. Only after they confirm:
+
+```bash
+gh release create vX.Y.Z --repo netbox-community/netbox \
+  --target <release commit SHA> \
+  --title "vX.Y.Z - YYYY-MM-DD" \
+  --notes-file $SCRATCH/release-body.md
+```
+
+The date in the title is `published` from `netbox/release.yaml`. The checklist targets `main`. Targeting the release commit's SHA instead keeps out anything pushed to `main` after the release PR merged.
+
+## 4. Watch the Workflow
+
+The tag push starts a `release.yml` run within a minute or so:
+
+```bash
+gh run list --repo netbox-community/netbox --workflow release.yml --event push --branch vX.Y.Z --json databaseId,status,url
+gh run watch <run id> --repo netbox-community/netbox --exit-status    # in the background
+gh run view <run id> --repo netbox-community/netbox --json jobs --jq '.jobs[] | "\(.databaseId) \(.name): \(.status) \(.conclusion)"'
+```
+
+Report the results of the build and verification jobs: **Build package artifacts**, **Verify dependency pins are in sync**, **Verify the sdist builds a wheel**, **Smoke test wheel CLI (no dependencies)**, and **Smoke test wheel install**.
+
+When **Publish package to PyPI** is waiting on the `pypi` environment, tell the user that a colleague must approve the deployment in the Actions UI (the environment prevents self-review). **Never approve a deployment yourself**, for example through the `pending_deployments` API.
+
+If any job fails, stop and follow [pypi-recovery.md](pypi-recovery.md). Never re-run the whole workflow.
+
+## 5. Verify the Publication
+
+1. The publish job deployed to the `pypi` environment:
+
+    ```bash
+    gh api "repos/netbox-community/netbox/deployments?environment=pypi&ref=vX.Y.Z" --jq '.[] | {id, environment, created_at}'
+    ```
+
+2. PyPI lists exactly one wheel and one sdist for the version:
+
+    ```bash
+    curl -sf https://pypi.org/pypi/netbox/X.Y.Z/json | python3 -c 'import json, sys
+    for f in json.load(sys.stdin)["urls"]:
+        print(f["packagetype"], f["filename"], f["digests"]["sha256"])'
+    ```
+
+3. Smoke-test the published wheel, following [Test-installing the wheel](../../../docs/development/building-the-package.md#test-installing-the-wheel) but installing from PyPI. It builds `psycopg[c]`, which needs the PostgreSQL client headers (`libpq-dev`) and a compiler. If pip can't find the new version yet, wait a minute and retry.
+
+    ```bash
+    python3 -m venv $SCRATCH/netbox-wheel-test
+    $SCRATCH/netbox-wheel-test/bin/python -m pip install --upgrade pip
+    $SCRATCH/netbox-wheel-test/bin/python -m pip install --no-cache-dir "netbox==X.Y.Z"
+    $SCRATCH/netbox-wheel-test/bin/netbox --version
+    PYTHONPATH=$PWD/scripts \
+    NETBOX_CONFIGURATION=smoketest_configuration \
+    NETBOX_ROOT=$SCRATCH/netbox-wheel-root \
+    NETBOX_SMOKETEST_BASE=$SCRATCH/netbox-wheel-root \
+    $SCRATCH/netbox-wheel-test/bin/netbox check
+    ```
+
+    `netbox --version` must report `X.Y.Z`, and `netbox check` must report no issues.
+
+## 6. Close Out the Milestone
+
+Confirm each change with the user before making it.
+
+```bash
+gh api 'repos/netbox-community/netbox/milestones?state=open&per_page=100' \
+  --jq '.[] | {number, title, due_on, open_issues}'
+```
+
+1. If milestone `vX.Y.Z` still has open issues, list them and ask whether to move them to the next milestone (`gh issue edit N --milestone vX.Y.(Z+1)`).
+2. Close it: `gh api -X PATCH repos/netbox-community/netbox/milestones/<number> -f state=closed`
+3. If milestone `vX.Y.(Z+1)` doesn't exist yet, create it, due two weeks after this release (patch releases ship every two weeks):
+
+    ```bash
+    gh api repos/netbox-community/netbox/milestones -f title=vX.Y.(Z+1) -f due_on=YYYY-MM-DDT00:00:00Z
+    ```
+
+## 7. Hand Off
+
+Give the user:
+
+- The release URL, the PyPI project URL (`https://pypi.org/project/netbox/X.Y.Z/`), and the workflow run URL
+- The milestone changes made
+- Remaining tasks for them: merging `main` into `feature`
+
+Finally, return the checkout to `main` (`git checkout main`), since step 1 left it on the detached release commit.
+
+## Why These Choices
+
+- **Stop on a missing approval gate.** Without one, the tag push uploads straight to PyPI, where a mistake can only be corrected by publishing a new version. The checklist depends on the gate as the final review point.
+- **Never approve or re-run publishing automatically.** Approval is deliberately a second person's decision, and re-running the wrong jobs rebuilds distributions that PyPI may already have accepted.
+- **Render the body from the merged docs.** The release notes in `docs/release-notes/` are the reviewed text; rendering from them avoids copying the PR body by hand.
+
+## References
+
+- [`docs/development/release-checklist.md`](../../../docs/development/release-checklist.md): The authoritative release process
+- [`docs/development/building-the-package.md`](../../../docs/development/building-the-package.md): Building and test-installing the package
+- [`.github/workflows/release.yml`](../../../.github/workflows/release.yml): The publishing workflow
+- [pypi-recovery.md](pypi-recovery.md): What to do when publishing fails

+ 70 - 0
.claude/skills/publish-release/pypi-recovery.md

@@ -0,0 +1,70 @@
+# Recovering From a Failed Publish
+
+This guide condenses the "Production PyPI uploads are final" warning in the [release checklist](../../../docs/development/release-checklist.md#publish-to-pypi). Every recovery action here is outward-facing: present the diagnosis and the proposed action, and wait for the user to confirm.
+
+## A Build or Verification Job Failed
+
+The publish jobs depend on every verification job, so nothing has been uploaded. Read the failed job's log:
+
+```bash
+gh run view <run id> --repo netbox-community/netbox --log-failed
+```
+
+- **Verify the release tag** (in the build job): the tag doesn't match the version in `netbox/release.yaml`. The tag points at the wrong commit, or `release.yaml` wasn't bumped.
+- **verify-dependencies**: `requirements.txt` has drifted from `base_requirements.txt`, or the wheel's `Requires-Dist` doesn't match `requirements.txt`.
+- **verify-sdist**: the sdist ships unexpected configuration files, or can't rebuild a valid wheel.
+- **smoke-test** or **cli-smoke-test**: the installed wheel fails `netbox check`, `netbox upgrade`, or its CLI commands.
+
+Each of these needs a fix merged to `main`. Since the tag and the GitHub release already exist, discuss with the user whether to delete and recreate the release and its tag at the fixed commit (nothing has reached PyPI, so the version is still free), or to ship the fix as the next patch version.
+
+## The Publish Job Failed
+
+First, find out whether PyPI accepted any file:
+
+```bash
+curl -s -o /dev/null -w '%{http_code}\n' https://pypi.org/pypi/netbox/X.Y.Z/json   # 404: nothing accepted
+gh run view <run id> --repo netbox-community/netbox --log-failed
+```
+
+Then:
+
+1. **No file accepted, and the cause can be fixed without changing the built distributions** (e.g. a trusted publisher or environment misconfiguration): once the user has fixed the cause, re-run only the failed publish job. This reuses the artifacts the original run built and verified:
+
+    ```bash
+    gh run view <run id> --repo netbox-community/netbox --json jobs --jq '.jobs[] | "\(.databaseId) \(.name) \(.conclusion)"'
+    gh run rerun <run id> --repo netbox-community/netbox --job <publish job id>
+    ```
+
+    **Never re-run all jobs** (or use the Re-run all jobs button): that rebuilds the distributions.
+
+2. **The fix requires changing package contents or metadata**: prepare a new NetBox version and tag. The current one can't be reused.
+
+3. **PyPI accepted either file**: don't retry. Production publishing fails on duplicate filenames by design, so a retry fails at the file already accepted. Instead:
+    - Record the accepted filenames and their SHA-256 hashes (the job log prints them).
+    - Ask the user to yank the incomplete release on PyPI. This is done in the PyPI web UI, by a project owner.
+    - Publish a new NetBox version. Never combine files from separate builds.
+
+## Optional: Rehearse on Test PyPI
+
+A rehearsal is useful after changes to packaging or publishing, but isn't needed for every release. Dispatch it from the release tag while the production deployment is still awaiting approval, and approve production only after the rehearsal succeeds:
+
+```bash
+gh workflow run release.yml --repo netbox-community/netbox --ref vX.Y.Z
+```
+
+The rehearsal rebuilds the distributions, so it validates the packaging and publishing path, not the exact files awaiting production approval. Test PyPI also never accepts the same filename twice: once it has accepted a file for a tag, dispatching that tag again fails, and a further rehearsal needs a new version.
+
+To test the rehearsal package, create a fresh venv (the one in step 5 of the skill doesn't exist yet at this point). Install the pinned runtime dependencies from PyPI first, then the candidate alone from Test PyPI:
+
+```bash
+rm -rf $SCRATCH/netbox-rehearsal
+python3 -m venv $SCRATCH/netbox-rehearsal
+$SCRATCH/netbox-rehearsal/bin/python -m pip install --upgrade pip
+$SCRATCH/netbox-rehearsal/bin/python -m pip install -r requirements.txt
+$SCRATCH/netbox-rehearsal/bin/python -m pip install --no-deps \
+  --index-url https://test.pypi.org/simple/ "netbox==X.Y.Z"
+```
+
+Then run `netbox --version` and `netbox check` from `$SCRATCH/netbox-rehearsal/bin/`, with the environment variables shown in step 5 of the skill.
+
+Dispatching the workflow from a branch, rather than a tag, runs the build and verification jobs as a dry run without publishing anywhere.

+ 1 - 1
.claude/skills/remove-model-field/SKILL.md

@@ -212,6 +212,6 @@ python manage.py migrate
 
 - Panel attrs reference: `netbox/netbox/ui/attrs.py`
 - Panel classes: `netbox/<app>/ui/panels.py`
-- Base filterset classes: `netbox/netbox/filtersets.py`
+- Base filterset classes: `netbox/netbox/filtersets/__init__.py`
 - `add-model-field` skill: `.claude/skills/add-model-field/SKILL.md` (reverse of this skill)
 - Contributing guide: `docs/development/extending-models.md`

+ 7 - 0
.github/VOUCHED.td

@@ -24,6 +24,7 @@ abrahamvegh
 abubnalitic-nbl
 adamboutcher
 adionit7
+ajay206
 alehaa
 alexhoulton
 alimickey
@@ -46,6 +47,7 @@ ciroiriarte
 clovehitch
 cmdrrobin
 cocofabio
+corencas
 corubba
 cory-kantar
 dansheps
@@ -116,12 +118,14 @@ mskalecki
 nfvelten
 olexandr88
 omripresent
+peio42
 peteeckel
 pheus
 pieterl75
 piyush-003
 pl0xym0r
 pobradovic08
+raffaele-maccarrone
 renatoalmeidaoliveira
 rfdrake
 rhyser9
@@ -129,11 +133,14 @@ rinna11
 robduffy2010
 roberth1993
 rodvand
+sebb0
+sensiblecoder
 sergiolpzgmz
 stalker-nn
 studioussagar
 stuntguy3000
 swoga
+syogo-suganoya
 thordreier
 tishka17
 tobiasge

+ 1 - 1
.github/workflows/ci.yml

@@ -85,7 +85,7 @@ jobs:
       - name: Check Python linting & PEP8 compliance
         uses: astral-sh/ruff-action@278981a28ce3188b1e39527901f38254bf3aac89  # v4.1.0
         with:
-          version: "0.15.20"
+          version: "0.17.4"
           args: "check --output-format=github"
           src: "netbox/"
 

+ 30 - 0
.github/workflows/enforce-issue-labels.yml

@@ -0,0 +1,30 @@
+name: Enforce issue templates
+
+on:
+  issues:
+    types:
+      - opened
+
+permissions:
+  issues: write
+
+jobs:
+  enforce-issue-labels:
+    name: Enforce Issue Labels
+    runs-on: ubuntu-slim
+    if: github.event.issue.labels[0] == null
+
+    steps:
+      - name: Close new issues without labels
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GH_REPO: ${{ github.repository }}
+          NUMBER: ${{ github.event.issue.number }}
+          COMMENT: >
+            This issue is being closed automatically because it was not opened using one of the provided forms.
+            Please note that we do not permit the use of AI agents to submit issues on a user's behalf.
+            You can resubmit the issue using one of the [available issue forms](${{ github.server_url }}/${{ github.repository }}/issues/new/choose).
+            Thanks!
+        run: |
+          gh issue close "$NUMBER" --reason "not planned" --comment "$COMMENT"
+          gh issue lock "$NUMBER" --reason off_topic

+ 0 - 21
.github/workflows/no-blank-issue.yml

@@ -1,21 +0,0 @@
-name: Enforce issue templates
-
-on:
-  issues:
-    types:
-      - opened
-      - reopened
-
-permissions:
-  issues: write
-
-jobs:
-  no-blank-issue:
-    name: No Blank Issue
-    runs-on: ubuntu-slim
-
-    steps:
-      - name: Close new issues without labels
-        uses: ldez/no-blank-issue@800e2d0c81c9e0ca7bdb58f3e7480a74602d91e0  # v1.2.0
-        with:
-          github-token: ${{ secrets.GITHUB_TOKEN }}

+ 1 - 1
.pre-commit-config.yaml

@@ -1,6 +1,6 @@
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
-  rev: v0.15.20
+  rev: v0.17.4
   hooks:
     - id: ruff
       name: "Ruff linter"

+ 1 - 0
AGENTS.md

@@ -273,6 +273,7 @@ All contributions must comply with the project's [AI policy](AI_POLICY.md). In s
 - AI tools may be used to explore the codebase and to generate code.
 - The contributor must fully understand, and be able to explain, every change submitted.
 - Issues, pull request descriptions, and comments must be written in the contributor's own voice. Anything you draft for these is a starting point for the contributor to review and rewrite: keep it brief and plain, and say so when handing it over.
+- Never open a GitHub issue on the contributor's behalf (e.g. via `gh issue create` or the API). The contributor must submit it themselves using one of the [issue forms](https://github.com/netbox-community/netbox/issues/new/choose); issues opened any other way are closed automatically.
 
 ## Branch & PR Conventions
 

+ 1 - 1
CHANGELOG.md

@@ -1 +1 @@
-The changelog has been moved to the [project release notes](https://docs.netbox.dev/en/stable/release-notes/).
+The changelog has been moved to the [project release notes](https://netboxlabs.com/docs/netbox/release-notes/).

+ 2 - 2
CONTRIBUTING.md

@@ -111,12 +111,12 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
 
 * Some other tips to keep in mind:
   * If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
-  * Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
+  * Check out our [developer docs](https://netboxlabs.com/docs/netbox/development/getting-started/) for tips on setting up your development environment.
   * All new functionality must include relevant tests where applicable.
 
 ## :jigsaw: Creating Plugins
 
-Do you have an idea for something you'd like to build in NetBox, but might not be right for the core project? NetBox includes a powerful and extensive [plugins framework](https://docs.netbox.dev/en/stable/plugins/) that enables users to develop their own custom data models and integrations.
+Do you have an idea for something you'd like to build in NetBox, but might not be right for the core project? NetBox includes a powerful and extensive [plugins framework](https://netboxlabs.com/docs/netbox/plugins/) that enables users to develop their own custom data models and integrations.
 
 Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
 

+ 18 - 11
README.md

@@ -14,7 +14,7 @@
   </p>
 </div>
 
-NetBox exists to empower network engineers. Since its release in 2016, it has become the go-to solution for modeling and documenting network infrastructure for thousands of organizations worldwide. As a successor to legacy IPAM and DCIM applications, NetBox provides a cohesive, extensive, and accessible data model for all things networked. By providing a single robust user interface and programmable APIs for everything from cable maps to device configurations, NetBox serves as the central source of truth for the modern network.
+NetBox exists to empower network engineers. For over a decade, it has been the go-to solution for modeling and documenting network infrastructure for thousands of organizations worldwide. NetBox offers a cohesive, extensive, and accessible data model for all things networked. By providing a single robust user interface and programmable APIs for everything from cable maps to device configurations, NetBox serves as the central source of truth for the modern network. It also forms the heart of the [NetBox Labs](https://netboxlabs.com/) platform, alongside [network discovery](https://netboxlabs.com/products/netbox-discovery/), [assurance](https://netboxlabs.com/products/netbox-assurance/), and [observability](https://netboxlabs.com/products/netbox-observability/).
 
 <p align="center">
   <a href="#netboxs-role">NetBox's Role</a> |
@@ -43,11 +43,11 @@ The diagram above illustrates the recommended deployment architecture for an aut
 
 ### Comprehensive Data Model
 
-Racks, devices, cables, IP addresses, VLANs, circuits, power, VPNs, and lots more: NetBox is built for networks. Its comprehensive and thoroughly inter-linked data model provides for natural and highly structured modeling of myriad network primitives that just isn't possible using general-purpose tools. And there's no need to waste time contemplating how to build out a database: Everything is ready to go upon installation.
+Racks, devices, cables, IP addresses, VLANs, circuits, power, cooling, VPNs, and lots more: NetBox is built for networks. Its comprehensive and thoroughly inter-linked data model provides for natural and highly structured modeling of myriad network primitives that just isn't possible using general-purpose tools. And there's no need to waste time contemplating how to build out a database: Everything is ready to go upon installation.
 
 ### Focused Development
 
-NetBox strives to meet a singular goal: Provide the best available solution for making network infrastructure programmatically accessible. Unlike "all-in-one" tools which awkwardly bolt on half-baked features in an attempt to check every box, NetBox is committed to its core function. NetBox provides the best possible solution for modeling network infrastructure, and provides rich APIs for integrating with tools that excel in other areas of network automation.
+NetBox strives to meet a singular goal: Provide the best available solution for making network infrastructure programmatically accessible. Unlike "all-in-one" tools which awkwardly bolt on half-baked features in an attempt to check every box, NetBox is committed to its core function. It focuses on modeling network infrastructure, and provides rich APIs for integrating with tools that excel in other areas of network automation.
 
 ### Extensible and Customizable
 
@@ -63,7 +63,7 @@ The data you put into NetBox is crucial to network operations. In addition to it
 
 ### Device Configuration Rendering
 
-NetBox can render user-created Jinja2 templates to generate device configurations from its own data. Configuration templates can be uploaded individually or pulled automatically from an external source, such as a git repository. Rendered configurations can be retrieved via the REST API for application directly to network devices via a provisioning tool such as Ansible or Salt.
+NetBox can render user-created Jinja2 templates to generate device configurations from its own data. Configuration templates can be uploaded individually or pulled automatically from an external source, such as a Git repository. Rendered configurations can be retrieved via the REST API for application directly to network devices via a provisioning tool such as Ansible or Salt.
 
 ### Custom Scripts
 
@@ -78,27 +78,34 @@ Users can define event rules to automatically trigger a custom script or outboun
 NetBox automatically logs the creation, modification, and deletion of all managed objects, providing a thorough change history. Changes can be attributed to the executing user, and related changes are grouped automatically by request ID.
 
 > [!NOTE]
-> A complete list of NetBox's myriad features can be found in [the introductory documentation](https://docs.netbox.dev/en/stable/introduction/).
+> A complete list of NetBox's myriad features can be found in [the introductory documentation](https://netboxlabs.com/docs/netbox/introduction/).
 
 ## Getting Started
 
 * Just want to explore? Check out [our public demo](https://demo.netbox.dev/) right now!
-* The [official documentation](https://docs.netbox.dev) offers a comprehensive introduction.
-* Check out [our wiki](https://github.com/netbox-community/netbox/wiki/Community-Contributions) for even more projects to get the most out of NetBox!
+* Ready to dive in? Grab your own [NetBox Cloud Free Tier](https://signup.netboxlabs.com/) instance in just a few minutes.
+* The [installation guide](https://netboxlabs.com/docs/netbox/installation/) will help you get your own deployment up and running.
+* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch, containerized approach.
+* The [official documentation](https://netboxlabs.com/docs/netbox/) offers a comprehensive introduction.
 
 ## Plugins
 
-NetBox's functionality can be extended through plugins, which add new models, views, and integrations on top of the core application. A few of the most popular plugins include:
+NetBox's functionality can be extended through plugins, which add new models, views, and integrations on top of the core application. A few notable plugins include:
 
-* [NetBox Branching](https://github.com/netboxlabs/netbox-branching) — Work with isolated, mergeable branches of your NetBox data
-* [NetBox Custom Objects](https://github.com/netboxlabs/netbox-custom-objects) — Define entirely new object types directly in the UI
+* [NetBox Branching](https://netboxlabs.com/docs/branching/) — Work with isolated, mergeable branches of your NetBox data
+* [NetBox Change Management](https://netboxlabs.com/docs/changes/) — Enforce review and approval policies on changes proposed in branches (Cloud & Enterprise)
+* [NetBox Custom Objects](https://netboxlabs.com/docs/custom-objects/) — Dynamically create new object types to extend the NetBox schema
+* [NetBox Asset Lifecycle](https://netboxlabs.com/docs/asset-lifecycle/) — Track equipment procurement from bills of materials through purchase orders and shipments (Cloud & Enterprise)
+* [NetBox NDX](https://netboxlabs.com/docs/ndx/) — Automatically import devices, modules, and more from the [NetBox Data Exchange](https://netboxlabs.com/ndx/) (Cloud & Enterprise)
 * [NetBox DNS](https://github.com/sys4/netbox-plugin-dns) — Manage DNS zones and records as an authoritative source of truth
 * [NetBox BGP](https://github.com/netbox-community/netbox-bgp) — Document and manage BGP sessions and routing policies
+* [NetBox WDM](https://github.com/jsenecal/netbox-wdm) — Manage WDM wavelengths, ITU channel plans, and wavelength services
+* [NetBox Security](https://github.com/andy-shady-org/netbox-security/) — Model firewall, security, and NAT policies
 * [Browse all plugins](https://netboxlabs.com/plugins/) — Discover the full catalog of available plugins
 
 ## Get Involved
 
-* Follow [@NetBoxOfficial](https://twitter.com/NetBoxOfficial) on Twitter!
+* Follow [@NetBoxOfficial](https://x.com/NetBoxOfficial) on X!
 * Join the conversation on [the discussion forum](https://github.com/netbox-community/netbox/discussions) and [Slack](https://netdev.chat/)!
 * Already a power user? You can [suggest a feature](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+feature&template=feature_request.yaml) or [report a bug](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+bug&template=bug_report.yaml) on GitHub.
 * Contributions from the community are encouraged and appreciated! Check out our [contributing guide](CONTRIBUTING.md) to get started.

+ 3 - 2
base_requirements.txt

@@ -146,8 +146,9 @@ redis
 requests
 
 # rq
+# Pinned below v3.0, whose new Worker.monitor_work_horse() signature breaks NetBox's tests
 # https://github.com/rq/rq/blob/master/CHANGES.md
-rq
+rq<3.0
 
 # Django app for social-auth-core
 # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
@@ -176,7 +177,7 @@ svgwrite
 # Tabular dataset library (for table-based exports)
 # Current: https://github.com/jazzband/tablib/releases
 # Previous: https://github.com/jazzband/tablib/blob/master/HISTORY.md
-# TODO: Remove in NetBox v5.0 along with utilities.export
+# TODO: Deprecated in v4.8; remove in v5.0 (see #23566)
 tablib
 
 # Timezone data (required by django-timezone-field on Python 3.9+)

+ 1 - 1
contrib/netbox-rq.service

@@ -1,6 +1,6 @@
 [Unit]
 Description=NetBox Request Queue Worker
-Documentation=https://docs.netbox.dev/
+Documentation=https://netboxlabs.com/docs/netbox/
 After=network-online.target
 Wants=network-online.target
 

+ 1 - 1
contrib/netbox.service

@@ -1,6 +1,6 @@
 [Unit]
 Description=NetBox WSGI Service
-Documentation=https://docs.netbox.dev/
+Documentation=https://netboxlabs.com/docs/netbox/
 After=network-online.target
 Wants=network-online.target
 

+ 46 - 25
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "info": {
         "title": "NetBox REST API",
-        "version": "4.7.4",
+        "version": "4.7.5",
         "license": {
             "name": "Apache v2 License"
         }
@@ -30180,7 +30180,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30200,7 +30200,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30213,7 +30213,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30226,7 +30226,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30239,7 +30239,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30252,7 +30252,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30265,7 +30265,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30278,7 +30278,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30291,7 +30291,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30304,7 +30304,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30317,7 +30317,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -30330,7 +30330,7 @@
                             "type": "array",
                             "items": {
                                 "type": "string",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             }
                         },
                         "explode": true,
@@ -277918,12 +277918,15 @@
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-12c1p",
@@ -277935,8 +277938,8 @@
                             "breakout-2c4p-8c1p-shuffle"
                         ],
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     "tenant": {
                         "oneOf": [
@@ -292638,12 +292641,15 @@
                                     "trunk-2c6p",
                                     "trunk-2c8p",
                                     "trunk-2c12p",
+                                    "trunk-3c8p",
+                                    "trunk-3c12p",
                                     "trunk-4c1p",
                                     "trunk-4c2p",
                                     "trunk-4c4p",
                                     "trunk-4c4p-shuffle",
                                     "trunk-4c6p",
                                     "trunk-4c8p",
+                                    "trunk-4c12p",
                                     "trunk-8c1p",
                                     "trunk-8c4p",
                                     "trunk-12c1p",
@@ -292655,8 +292661,8 @@
                                     "breakout-2c4p-8c1p-shuffle"
                                 ],
                                 "type": "string",
-                                "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                                "x-spec-enum-id": "019cf94a890ea9e5"
+                                "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                                "x-spec-enum-id": "6a87b4ee518fe970"
                             },
                             "label": {
                                 "type": "string",
@@ -292675,12 +292681,15 @@
                                     "2C6P trunk",
                                     "2C8P trunk",
                                     "2C12P trunk",
+                                    "3C8P trunk",
+                                    "3C12P trunk",
                                     "4C1P trunk",
                                     "4C2P trunk",
                                     "4C4P trunk",
                                     "4C4P trunk (shuffle)",
                                     "4C6P trunk",
                                     "4C8P trunk",
+                                    "4C12P trunk",
                                     "8C1P trunk",
                                     "8C4P trunk",
                                     "12C1P trunk",
@@ -293029,12 +293038,15 @@
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-12c1p",
@@ -293046,8 +293058,8 @@
                             "breakout-2c4p-8c1p-shuffle"
                         ],
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     "tenant": {
                         "oneOf": [
@@ -320094,12 +320106,15 @@
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-12c1p",
@@ -320111,8 +320126,8 @@
                             "breakout-2c4p-8c1p-shuffle"
                         ],
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     "tenant": {
                         "oneOf": [
@@ -338087,12 +338102,15 @@
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-12c1p",
@@ -338105,8 +338123,8 @@
                             ""
                         ],
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     "tenant": {
                         "oneOf": [
@@ -363078,12 +363096,15 @@
                             "trunk-2c6p",
                             "trunk-2c8p",
                             "trunk-2c12p",
+                            "trunk-3c8p",
+                            "trunk-3c12p",
                             "trunk-4c1p",
                             "trunk-4c2p",
                             "trunk-4c4p",
                             "trunk-4c4p-shuffle",
                             "trunk-4c6p",
                             "trunk-4c8p",
+                            "trunk-4c12p",
                             "trunk-8c1p",
                             "trunk-8c4p",
                             "trunk-12c1p",
@@ -363096,8 +363117,8 @@
                             ""
                         ],
                         "type": "string",
-                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
-                        "x-spec-enum-id": "019cf94a890ea9e5"
+                        "description": "* `single-1c1p` - 1C1P\n* `single-1c2p` - 1C2P\n* `single-1c4p` - 1C4P\n* `single-1c6p` - 1C6P\n* `single-1c8p` - 1C8P\n* `single-1c12p` - 1C12P\n* `single-1c16p` - 1C16P\n* `trunk-2c1p` - 2C1P trunk\n* `trunk-2c2p` - 2C2P trunk\n* `trunk-2c4p` - 2C4P trunk\n* `trunk-2c4p-shuffle` - 2C4P trunk (shuffle)\n* `trunk-2c6p` - 2C6P trunk\n* `trunk-2c8p` - 2C8P trunk\n* `trunk-2c12p` - 2C12P trunk\n* `trunk-3c8p` - 3C8P trunk\n* `trunk-3c12p` - 3C12P trunk\n* `trunk-4c1p` - 4C1P trunk\n* `trunk-4c2p` - 4C2P trunk\n* `trunk-4c4p` - 4C4P trunk\n* `trunk-4c4p-shuffle` - 4C4P trunk (shuffle)\n* `trunk-4c6p` - 4C6P trunk\n* `trunk-4c8p` - 4C8P trunk\n* `trunk-4c12p` - 4C12P trunk\n* `trunk-8c1p` - 8C1P trunk\n* `trunk-8c4p` - 8C4P trunk\n* `trunk-12c1p` - 12C1P trunk\n* `trunk-24c1p` - 24C1P trunk\n* `breakout-1c2p-2c1p` - 1C2P:2C1P breakout\n* `breakout-1c4p-4c1p` - 1C4P:4C1P breakout\n* `breakout-1c6p-6c1p` - 1C6P:6C1P breakout\n* `breakout-1c8p-8c1p` - 1C8P:8C1P breakout\n* `breakout-2c4p-8c1p-shuffle` - 2C4P:8C1P breakout (shuffle)",
+                        "x-spec-enum-id": "6a87b4ee518fe970"
                     },
                     "tenant": {
                         "oneOf": [

+ 4 - 7
docs/development/application-registry.md

@@ -20,19 +20,16 @@ A dictionary mapping data backend types to their respective classes. These are u
 
 A dictionary mapping each model (identified by its app and label) to its filterset class, if one has been registered for it. Filtersets are registered using the `@register_filterset` decorator.
 
+### `job_result_urls`
+
+A dictionary mapping `(model_label, job_name)` pairs to `(function, raise_exceptions)` tuples registered with `core.utils.register_job_result_url()`. Model labels use `<app_label>.<model_name>`; a `None` job name provides a fallback for that model. See [Result URLs](../plugins/development/background-jobs.md#result-urls) for details.
+
 ### `model_features`
 
 A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the functions used to qualify a model as supporting each feature. Model features are registered using the `register_model_feature()` function in `netbox.utils`.
 
 Core model features are listed in the [features matrix](./models.md#features-matrix).
 
-### `models`
-
-!!! warning "Deprecated"
-    Usage of this key has been deprecated and will be removed in NetBox v4.7. Use `ObjectType.objects.public()` to find registered models.
-
-This key lists all models which have been registered in NetBox which are not designated for private use. (Setting `_netbox_private` to True on a model excludes it from this list.) As with individual features under `model_features`, models are organized by app label.
-
 ### `pk_allocators`
 
 A list of callables which may supply primary keys for new objects, consulted in order. Primary key allocators are registered using the `register_pk_allocator()` decorator in `netbox.models`. (See the [plugin development documentation](../plugins/development/models.md#primary-key-allocators) for details.)

+ 3 - 0
docs/development/release-checklist.md

@@ -10,6 +10,9 @@ While major releases generally introduce some very substantial changes to the ap
 
 For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist.
 
+!!! tip "Claude Code skills"
+    Maintainers using Claude Code can automate most of the patch release process with the `prepare-release`, `draft-release-notes`, and `publish-release` skills in `.claude/skills/`. When changing this checklist, update those skills to match.
+
 ## Minor Version Releases
 
 ### Address Constrained Dependencies

+ 42 - 1
docs/features/context-data.md

@@ -78,9 +78,50 @@ When the context data for a device at this site is rendered, the second, higher-
 
 Data from the higher-weight context overwrites conflicting data from the lower-weight context, while the non-conflicting portion of the lower-weight context (the list of NTP servers) is preserved.
 
+### Merging Behavior
+
+Config contexts are applied in order of increasing weight (contexts with the same weight are ordered by name), with each context deep-merged into the result of those before it:
+
+* Where both contexts define a key as a dictionary, and the higher-weight dictionary is not empty, the two dictionaries are merged recursively, applying these same rules at each level.
+* Any other value (a list, string, number, boolean, `null`, or empty dictionary) in the higher-weight context replaces the lower-weight value entirely. Lists are not concatenated.
+
+For example, suppose a lower-weight context defines the following data:
+
+```json
+{
+    "snmp": {
+        "contact": "noc@example.com",
+        "location": "Region A"
+    }
+}
+```
+
+And a higher-weight context defines:
+
+```json
+{
+    "snmp": {
+        "location": "Site 12"
+    }
+}
+```
+
+Because both values of `snmp` are dictionaries, they are merged rather than replaced:
+
+```json
+{
+    "snmp": {
+        "contact": "noc@example.com",
+        "location": "Site 12"
+    }
+}
+```
+
+To replace a dictionary outright instead of merging into it, create a config context with an intermediate weight, assigned to the same objects as the higher-weight context, which sets the key to `null` or to an empty dictionary (`{}`). Either value replaces the lower-weight dictionary, and the higher-weight context's dictionary is then applied in full.
+
 ## Local Context Data
 
-Devices and virtual machines may also have a local context data defined. This local context will _always_ take precedence over any separate config context objects which apply to the device/VM. This is useful in situations where we need to call out a specific deviation in the data for a particular object.
+Devices and virtual machines may also have a local context data defined. This local context will _always_ take precedence over any separate config context objects which apply to the device/VM. It is merged last, following the same [merging rules](#merging-behavior) as config contexts. This is useful in situations where we need to call out a specific deviation in the data for a particular object.
 
 !!! warning
     If you find that you're routinely defining local context data for many individual devices or virtual machines, [custom fields](./customization.md#custom-fields) may offer a more effective solution.

+ 57 - 15
docs/index.md

@@ -3,54 +3,96 @@
 
 # The Premier Network Source of Truth
 
-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 is the leading solution for modeling and documenting modern networks. It forms the core of the [NetBox Labs](https://netboxlabs.com/) platform, alongside [network discovery](https://netboxlabs.com/products/netbox-discovery/), [assurance](https://netboxlabs.com/products/netbox-assurance/), and [observability](https://netboxlabs.com/products/netbox-observability/). 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 is available in three editions:
+
+* [**NetBox Community**](https://netboxlabs.com/community/) - The free, open source edition, which you deploy and manage yourself
+* [**NetBox Cloud**](https://netboxlabs.com/netbox-cloud/) - A fully managed service hosted by NetBox Labs
+* [**NetBox Enterprise**](https://netboxlabs.com/netbox-enterprise/) - A self-hosted deployment of the NetBox Labs platform
 
 [![NetBox UI](./media/screenshots/home-light.png)](./media/screenshots/home-light.png)
 
 ## :material-server-network: Built for Networks
 
-Unlike general-purpose configuration management databases (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:
+Unlike general-purpose configuration management databases (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 management to cabling to overlays and more:
 
 * Hierarchical regions, sites, and locations
 * Racks, devices, and device components
-* Cables and wireless connections
+* Device and module types for modular hardware
+* Virtual chassis and virtual device contexts
+* Cables and cable bundles
+* Wireless LANs and point-to-point links
 * Power distribution tracking
-* Data circuits and providers
+* Data center cooling infrastructure
+* Physical and virtual circuits and providers
 * Virtual machines and clusters
 * IP prefixes, ranges, and addresses
+* MAC addresses
 * VRFs and route targets
 * FHRP groups (VRRP, HSRP, etc.)
-* AS numbers
-* VLANs and scoped VLAN groups
+* AS numbers and ranges
+* VLANs, scoped VLAN groups, and VLAN translation
 * L2VPN overlays
+* VPN tunnels and IKE/IPSec policies
+* Application services
 * Tenancy assignments
+* Resource ownership
 * Contact management
 
 ## :material-hammer-wrench: Customizable & Extensible
 
-In addition to its expansive and robust data model, NetBox offers myriad mechanisms through which it can be customized and extended. Its powerful plugins architecture enables users to extend the application to meet their needs with minimal development effort.
+In addition to its expansive and robust data model, NetBox offers myriad mechanisms through which it can be customized and extended. Its powerful plugin architecture enables users to extend the application to meet their needs with minimal development effort.
 
 * Custom fields
-* Custom model validation
+* Tags
+* Custom links
+* Custom validation & protection rules
 * Export templates
-* Event rules
+* Configuration contexts & templates
+* Custom scripts
+* Event rules & webhooks
+* Data synchronization from Git and S3
 * Plugins
 * REST & GraphQL APIs
 
 ## :material-lock-open: Always Open
 
-Because NetBox is an open source application licensed under [Apache 2](https://www.apache.org/licenses/LICENSE-2.0.html), its entire code base is completely accessible to the end user, and there's never a risk of vendor lock-in. Additionally, NetBox development is an entirely public, community-driven process to which everyone can provide input.
+Because the core NetBox application is open source and licensed under [Apache 2](https://www.apache.org/licenses/LICENSE-2.0.html), its entire code base is completely accessible to the end user, and there's never a risk of vendor lock-in. Additionally, NetBox development is an entirely public, community-driven process to which everyone can provide input.
 
 !!! tip "NetBox Development"
     Interested in contributing to NetBox? Check out our [GitHub repository](https://github.com/netbox-community/netbox) to get started!
 
 ## :material-language-python: Powered by Python
 
-NetBox is built on the enormously popular [Django](http://www.djangoproject.com/) framework for the Python programming language, already a favorite among network engineers. Users can leverage their existing skills coding Python tools to extend NetBox's already vast functionality via custom scripts and plugins.
+NetBox is built on the enormously popular [Django](https://www.djangoproject.com/) framework for the Python programming language, already a favorite among network engineers. Users can leverage their existing skills coding Python tools to extend NetBox's already vast functionality via custom scripts and plugins.
 
 ## :material-flag: Getting Started
 
-* Try out our [public demo](https://demo.netbox.dev/) if you want to jump right in
-* The [installation guide](./installation/index.md) will help you get your own deployment up and running
-* Or try the community [Docker image](https://github.com/netbox-community/netbox-docker) for a low-touch approach
-* [NetBox Cloud](https://netboxlabs.com/netbox-cloud) is a managed solution offered by [NetBox Labs](https://netboxlabs.com/)
+<div class="grid cards" markdown>
+
+-   :material-cloud-outline:{ .lg .middle } __NetBox Cloud Free Tier__
+
+    ---
+
+    Skip the installation process and grab your own NetBox Cloud instance, preconfigured and ready to go in minutes. Completely free!
+
+    :octicons-arrow-right-24: [Sign Up](https://signup.netboxlabs.com/)
+
+-   :material-download:{ .lg .middle } __Install NetBox__
+
+    ---
+
+    Follow the installation guide to deploy NetBox Community on your own infrastructure.
+
+    :octicons-arrow-right-24: [Installation Guide](./installation/index.md)
+
+-   :material-docker:{ .lg .middle } __Docker Image__
+
+    ---
+
+    Use the community-maintained Docker image for a low-touch, containerized deployment.
+
+    :octicons-arrow-right-24: [NetBox Docker](https://github.com/netbox-community/netbox-docker)
+
+</div>

+ 1 - 1
docs/installation/4a-gunicorn.md

@@ -45,7 +45,7 @@ You should see output similar to the following:
 ● netbox.service - NetBox WSGI Service
      Loaded: loaded (/etc/systemd/system/netbox.service; enabled; preset: enabled)
      Active: active (running) since Mon 2026-01-26 11:00:00 CST; 7s ago
-       Docs: https://docs.netbox.dev/
+       Docs: https://netboxlabs.com/docs/netbox/
    Main PID: 7283 (gunicorn)
       Tasks: 6 (limit: 4545)
      Memory: 556.1M (peak: 556.3M)

+ 1 - 1
docs/installation/4b-uwsgi.md

@@ -68,7 +68,7 @@ You should see output similar to the following:
 ● netbox.service - NetBox WSGI Service
      Loaded: loaded (/etc/systemd/system/netbox.service; enabled; vendor preset: enabled)
      Active: active (running) since Mon 2021-08-30 04:02:36 UTC; 14h ago
-       Docs: https://docs.netbox.dev/
+       Docs: https://netboxlabs.com/docs/netbox/
    Main PID: 1140492 (uwsgi)
       Tasks: 19 (limit: 4683)
      Memory: 666.2M

BIN
docs/media/misc/reference_architecture.png


+ 1 - 1
docs/models/dcim/module.md

@@ -10,7 +10,7 @@ Similar to devices, modules are instantiated from [module types](./moduletype.md
 
 An installed module can be moved to a different module bay after creation. The destination bay must be enabled and unoccupied. Moving a module relocates its entire subtree: the components installed by the module, the module bays belonging to it, and any child modules installed within those bays.
 
-Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay's nesting depth.
+Component names, labels, and module bay positions derived from the module type's templates (for example, names containing `{module}`) are re-resolved for the destination bay. A component is renamed only when its current name matches exactly one of the module type's templates as resolved for the source bay; components whose names do not match any template resolution (including manually renamed components) are preserved as-is. All resulting names are validated against the destination device before the move is applied. A move is rejected when a template-derived name, label, or position would exceed the destination field's maximum length. A move is also rejected when a component's current value matched a template for the source bay but that template cannot be resolved for the destination bay, because the bay's nesting depth does not match the template's placeholders or a bay position that a placeholder refers to is not defined.
 
 Moving a module to a different device is supported only when the moved components carry no active topology or device-scoped configuration. A cross-device move is rejected while any moved component is cabled or marked as connected, has attached inventory items, or any moved interface has IP addresses, FHRP group assignments, tunnel terminations, L2VPN terminations, virtual circuit terminations, wireless links, wireless LAN assignments, VLANs (untagged, tagged, or Q-in-Q service), a VLAN translation policy, VDC assignments, or a VRF. A parent, bridge, LAG, power outlet to power port, cooling outflow to cooling intake, or front/rear port mapping relation crossing the moved module's boundary in either direction also blocks the move. MAC addresses move together with their interfaces.
 

+ 1 - 1
docs/models/extras/configcontext.md

@@ -12,7 +12,7 @@ A unique human-friendly name.
 
 ### Weight
 
-A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight.
+A numeric value which influences the order in which context data is merged. Contexts with a lower weight are merged before those with a higher weight, and contexts with the same weight are merged in order of name. See [merging behavior](../../features/context-data.md#merging-behavior) for details.
 
 ### Profile
 

+ 25 - 0
docs/plugins/development/background-jobs.md

@@ -93,6 +93,31 @@ class MyModel(JobsMixin, NetBoxModel):
 ```
 
 
+### Result URLs
+
+Use `core.utils.register_job_result_url()` to link jobs to your plugin's result page. Pass a model class or an `<app_label>.<model_name>` string, and optionally a `job_name`. The function receives a `Job` and returns a URL, or `None` to try the next fallback.
+
+NetBox tries the registration for the model and job name first, then the model alone, then the generic Job view. Each model/name pair can be registered only once.
+
+`NoReverseMatch` and `ObjectDoesNotExist` are logged as warnings; any other exception is logged as an error with its traceback. Either way, the function is treated as having returned `None`. If your function uses `job.object`, handle it being `None` after deletion.
+
+The URL is cached on each `Job` instance. Later changes to that instance do not update the cached URL.
+
+#### Example
+
+```python title="jobs.py"
+from django.urls import reverse
+from core.utils import register_job_result_url
+from .models import MyModel
+
+@register_job_result_url(MyModel, job_name='My Test Job')
+def my_test_job_result_url(job):
+    return reverse('plugins:my_plugin:mymodel_results', args=[job.object_id])
+```
+
+!!! note
+    As with [system jobs](#system-jobs), ensure that any such functions are imported on initialization. Otherwise, they won't be registered.
+
 ### System Jobs
 
 Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run.

+ 13 - 0
docs/plugins/development/filtersets.md

@@ -90,6 +90,19 @@ class MyFilterSet(NetBoxModelFilterSet):
 
 The `search` method is also used by the `q` filter in `NetBoxModelFilterSet` which in turn is used by the Search field in the filters tab.
 
+### Legacy Evaluation
+
+!!! info "This feature was introduced in NetBox v4.8."
+
+NetBox evaluates only the filters whose parameters are present in a request. A parameter supplied with an empty value (e.g. `?status=`) is still validated and passed to its filter. Required and disabled filters are always evaluated, as are filters with a custom widget (or subwidget) which overrides `value_from_datadict()`. A filter set which depends on every filter being evaluated for each request, as in earlier releases, can set `legacy_evaluation` to restore that behavior. This is intended as a temporary measure, and will be revisited in NetBox v5.0.
+
+```python
+class MyFilterSet(NetBoxModelFilterSet):
+    legacy_evaluation = True
+```
+
+Legacy evaluation also applies automatically to a filter set which uses a form prefix, customizes its form (via `Meta.form`, `get_form_class()`, or the `form` property), or inherits from a mixin listed after the NetBox base class (e.g. `class MyFilterSet(NetBoxModelFilterSet, MyMixin)`) which overrides `__init__()`, `filter_queryset()`, `errors`, or `is_valid()`. To evaluate only the supplied filters, list such a mixin before the NetBox base class.
+
 ## Filter Classes
 
 ### TagFilter

+ 20 - 0
docs/plugins/development/views.md

@@ -47,6 +47,26 @@ A URL pattern has three components:
 
 This makes our view accessible at the URL `/plugins/animal-sounds/random/`. (Remember, our `AnimalSoundsConfig` class sets our plugin's base URL to `animal-sounds`.) Viewing this URL should show the base NetBox template with our custom content inside it.
 
+### Authentication
+
+!!! info "This behavior was introduced in NetBox v4.8."
+
+When [`LOGIN_REQUIRED`](../../configuration/security.md#login_required) is enabled (the default), NetBox requires authentication for every view, including those provided by plugins. An unauthenticated request is redirected to the login page. REST API views are exempt, as they authenticate requests themselves.
+
+A view intended for anonymous access, such as a health check or an inbound webhook receiver, must be marked with Django's [`login_not_required`](https://docs.djangoproject.com/en/stable/ref/decorators/#django.contrib.auth.decorators.login_not_required) decorator. A class-based view can be wrapped where its URL is registered. A view which accepts `POST` requests from external clients must also be exempted from CSRF protection with [`csrf_exempt`](https://docs.djangoproject.com/en/stable/ref/csrf/#django.views.decorators.csrf.csrf_exempt), and should authenticate those requests by other means (for example, by validating a webhook signature).
+
+```python
+from django.contrib.auth.decorators import login_not_required
+from django.urls import path
+from django.views.decorators.csrf import csrf_exempt
+from . import views
+
+urlpatterns = [
+    path('health/', login_not_required(views.HealthCheckView.as_view()), name='health_check'),
+    path('webhook/', csrf_exempt(login_not_required(views.WebhookReceiverView.as_view())), name='webhook_receiver'),
+]
+```
+
 ## NetBox Model Views
 
 NetBox provides several generic view classes and additional helper functions, to simplify the implementation of plugin logic. These are recommended to be used whenever possible to keep the maintenance overhead of plugins low.

+ 40 - 0
docs/release-notes/version-4.7.md

@@ -1,5 +1,45 @@
 # NetBox v4.7
 
+## v4.7.5 (2026-11-10)
+
+### Enhancements
+
+* [#23470](https://github.com/netbox-community/netbox/issues/23470) - Add 3C8P, 3C12P, and 4C12P trunk cable profiles
+
+### Performance Improvements
+
+* [#23505](https://github.com/netbox-community/netbox/issues/23505) - Avoid redundant query conditions when filtering by a non-unique name or slug, such as MAC addresses by `interface` or device components by `device`
+* [#23562](https://github.com/netbox-community/netbox/issues/23562) - Batch the loading of related objects read by export templates, rather than querying them for each object
+* [#23563](https://github.com/netbox-community/netbox/issues/23563) - Batch the loading of related objects when rendering global search results
+* [#23564](https://github.com/netbox-community/netbox/issues/23564) - Batch the loading of cable terminations and their parent objects when listing cables, cable terminations, ports, and power feeds via the REST API
+* [#23565](https://github.com/netbox-community/netbox/issues/23565) - Batch the loading of deferred fields and related objects which the GraphQL query optimizer can't anticipate, such as those read when resolving `link_peers` and `connected_endpoints`
+
+### Bug Fixes
+
+* [#23185](https://github.com/netbox-community/netbox/issues/23185) - Prevent the deletion of a region or site group from also deleting the circuit terminations attached to its sites and locations
+* [#23260](https://github.com/netbox-community/netbox/issues/23260) - Fix exception when applying migration `dcim.0245_consolidate_unique_constraints` on installations whose location, region, or site group unique constraints have auto-generated names
+* [#23407](https://github.com/netbox-community/netbox/issues/23407) - Restrict the image attachments listed under an object's Images tab to those the user is permitted to view
+* [#23446](https://github.com/netbox-community/netbox/issues/23446) - Prevent duplicate submissions of object add and edit forms (such as duplicate image attachments) by disabling the submit button after the first click
+* [#23447](https://github.com/netbox-community/netbox/issues/23447) - Fix exception when ordering REST API results by an object count field (e.g. `count_ipaddresses`) which is omitted from the response by `brief` or `fields`
+* [#23477](https://github.com/netbox-community/netbox/issues/23477) - Include the `asn_count` field in the REST API representation of ASN ranges
+* [#23478](https://github.com/netbox-community/netbox/issues/23478) - Validate `{module}` placeholders in component labels and module bay templates when installing a module, rather than raising an exception or resolving them to an empty string
+* [#23486](https://github.com/netbox-community/netbox/issues/23486) - Record pre-change data in the change log for virtual chassis members when creating, editing, or deleting a virtual chassis
+* [#23510](https://github.com/netbox-community/netbox/issues/23510) - Prevent a queued background job from running after it has been deleted or marked as failed
+* [#23511](https://github.com/netbox-community/netbox/issues/23511) - Display the correct file path on a script's Source tab, and identify the script file (rather than `<string>`) in error tracebacks
+* [#23534](https://github.com/netbox-community/netbox/issues/23534) - Fix `IntegrityError` exception when completing a job whose owner already has a notification for it
+* [#23535](https://github.com/netbox-community/netbox/issues/23535) - Prevent a duplicate recurring sync job from being created when a data source is saved as its scheduled sync finishes
+* [#23542](https://github.com/netbox-community/netbox/issues/23542) - Fix `FileNotFoundError` exception when synchronizing a local data source whose subdirectory paths repeat the root path (e.g. `/scripts/scripts_old/`)
+* [#23558](https://github.com/netbox-community/netbox/issues/23558) - Fix errors when editing an API token without permission to view users, or from the user's own API tokens view
+* [#23559](https://github.com/netbox-community/netbox/issues/23559) - Fix the prefetching of related objects shown as display attributes in global search results
+* [#23574](https://github.com/netbox-community/netbox/issues/23574) - Fix exception when passing `null` to several filters (e.g. `present_in_vrf_id`, `available_at_site`, and `interface_id`), and support filtering data sources by `sync_interval=null`
+* [#23584](https://github.com/netbox-community/netbox/issues/23584) - Enforce SSO allowlist settings (e.g. `WHITELISTED_DOMAINS` and `WHITELISTED_EMAILS`) by adding `auth_allowed` to the default `SOCIAL_AUTH_PIPELINE`
+* [#23585](https://github.com/netbox-community/netbox/issues/23585) - Enforce view permissions on parent and related objects when adding individual components or component templates, and when bulk adding prefixes, IP addresses, and VLANs
+* [#23595](https://github.com/netbox-community/netbox/issues/23595) - Implement `update_dependent_objects()` for wireless links, circuit terminations, and channel subinterfaces, to rebuild cable paths after writes which bypass `save()`
+* [#23596](https://github.com/netbox-community/netbox/issues/23596) - Record pre-change data in the change log for components adopted when installing a module
+* [#23597](https://github.com/netbox-community/netbox/issues/23597) - Record change log entries and trigger event rules for port mappings created from device type and module type templates, or moved with a module to another device
+
+---
+
 ## v4.7.4 (2026-10-27)
 
 !!! warning "Image Thumbnails on Remote Storage"

+ 1 - 1
mkdocs.yml

@@ -1,7 +1,7 @@
 # Note: NetBox has migrated from MkDocs to Zensical
 site_name: NetBox Documentation
 site_dir: netbox/project-static/docs
-site_url: https://docs.netbox.dev/
+site_url: https://netboxlabs.com/docs/netbox/
 repo_name: netbox-community/netbox
 repo_url: https://github.com/netbox-community/netbox
 theme:

+ 27 - 0
netbox/account/tests.py

@@ -0,0 +1,27 @@
+from django.test import override_settings
+from django.urls import reverse
+
+from users.models import Token
+from utilities.testing import TestCase
+
+
+@override_settings(DEFAULT_PERMISSIONS={
+    'users.change_token': ({'user': '$user'},),
+})
+class UserTokenEditTestCase(TestCase):
+
+    def test_edit_own_token(self):
+        token = Token.objects.create(user=self.user, description='Original description')
+        url = reverse('account:usertoken_edit', kwargs={'pk': token.pk})
+
+        response = self.client.get(url)
+        self.assertEqual(response.status_code, 200)
+
+        response = self.client.post(url, {
+            'description': 'Updated description',
+            'enabled': 'on',
+        })
+        self.assertEqual(response.status_code, 302)
+        token.refresh_from_db()
+        self.assertEqual(token.description, 'Updated description')
+        self.assertEqual(token.user, self.user)

+ 4 - 0
netbox/account/views.py

@@ -6,6 +6,7 @@ from django.contrib import messages
 from django.contrib.auth import login as auth_login
 from django.contrib.auth import logout as auth_logout
 from django.contrib.auth import update_session_auth_hash
+from django.contrib.auth.decorators import login_not_required
 from django.contrib.auth.forms import AuthenticationForm, PasswordChangeForm
 from django.contrib.auth.mixins import LoginRequiredMixin
 from django.contrib.auth.models import update_last_login
@@ -44,6 +45,7 @@ from utilities.views import register_model_view
 #
 
 
+@method_decorator(login_not_required, name='dispatch')
 class LoginView(View):
     """
     Perform user authentication via the web UI.
@@ -169,6 +171,7 @@ class LoginView(View):
         return HttpResponseRedirect(redirect_url)
 
 
+@method_decorator(login_not_required, name='dispatch')
 class LogoutView(View):
     """
     Deauthenticate a web user.
@@ -191,6 +194,7 @@ class LogoutView(View):
         return response
 
 
+@method_decorator(login_not_required, name='dispatch')
 class SocialAuthBeginView(View):
     """
     Initiate authentication against a social auth (SSO) backend.

+ 34 - 0
netbox/circuits/migrations/0061_circuitterminations_cached_ancestors_on_delete.py

@@ -0,0 +1,34 @@
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+    dependencies = [
+        ('circuits', '0060_denormalization_triggers'),
+        ('dcim', '0251_fix_ltree_cascade_triggers'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='_region',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='circuit_terminations',
+                to='dcim.region',
+            ),
+        ),
+        migrations.AlterField(
+            model_name='circuittermination',
+            name='_site_group',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.SET_NULL,
+                related_name='circuit_terminations',
+                to='dcim.sitegroup',
+            ),
+        ),
+    ]

+ 1 - 1
netbox/circuits/migrations/0061_generated_fields.py → netbox/circuits/migrations/0062_generated_fields.py

@@ -15,7 +15,7 @@ import utilities.fields
 class Migration(migrations.Migration):
 
     dependencies = [
-        ("circuits", "0060_denormalization_triggers"),
+        ("circuits", "0061_circuitterminations_cached_ancestors_on_delete"),
     ]
 
     operations = [

+ 11 - 2
netbox/circuits/models/circuits.py

@@ -9,6 +9,7 @@ from django.utils.translation import gettext_lazy as _
 
 from circuits.choices import *
 from dcim.models import CabledObjectModel
+from dcim.utils import rebuild_paths
 from netbox.models import ChangeLoggedModel, OrganizationalModel, PrimaryModel
 from netbox.models.features import (
     ContactsMixin,
@@ -345,16 +346,17 @@ class CircuitTermination(
         blank=True,
         null=True
     )
+    # SET_NULL, not CASCADE: may cache an ancestor. Direct terminations cascade via direct_circuit_terminations.
     _region = models.ForeignKey(
         to='dcim.Region',
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
         related_name='circuit_terminations',
         blank=True,
         null=True
     )
     _site_group = models.ForeignKey(
         to='dcim.SiteGroup',
-        on_delete=models.CASCADE,
+        on_delete=models.SET_NULL,
         related_name='circuit_terminations',
         blank=True,
         null=True
@@ -499,3 +501,10 @@ class CircuitTermination(
             )
         except CircuitTermination.DoesNotExist:
             return None
+
+    def update_dependent_objects(self):
+        """
+        Rebuild the CablePaths which traverse this CircuitTermination or its peer.
+        """
+        rebuild_paths([t for t in (self, self.get_peer_termination()) if t])
+    update_dependent_objects.alters_data = True

+ 66 - 1
netbox/circuits/tests/test_models.py

@@ -8,13 +8,14 @@ from circuits.models import Circuit, CircuitTermination, CircuitType, Provider,
 from circuits.models.circuits import _set_circuit_terminations
 from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange
-from dcim.models import Location, Region, Site, SiteGroup
+from dcim.models import Cable, Interface, Location, Region, Site, SiteGroup
 from extras.choices import CustomFieldStatusChoices, CustomFieldTypeChoices
 from extras.models import CustomField
 from netbox.choices import DistanceUnitChoices
 from netbox.context_managers import event_tracking
 from users.models import User
 from utilities.conversion import to_meters
+from utilities.testing import create_test_device
 from utilities.testing.mixins import NormalizedMeasurementTestMixin
 
 
@@ -201,6 +202,53 @@ class CircuitTerminationTestCase(TestCase):
         self.assertIn('Please select a Provider Network.', errors[NON_FIELD_ERRORS])
         self.assertNotIn('termination_id', errors)
 
+    @tag('regression')  # Ref: #23185
+    def test_deleting_site_group_keeps_terminations_of_its_sites(self):
+        """Deleting a SiteGroup deletes only the terminations attached to it directly."""
+        site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
+        site = Site.objects.create(name='Site 3', slug='site-3', group=site_group)
+        location = Location.objects.create(name='Location 1', slug='location-1', site=site)
+        direct = CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=site_group)
+        kept = (
+            CircuitTermination.objects.create(circuit=self.circuits[0], term_side='Z', termination=site),
+            CircuitTermination.objects.create(circuit=self.circuits[1], term_side='A', termination=location),
+        )
+        interface = Interface.objects.create(device=create_test_device('Device 1'), name='Interface 1')
+        cable = Cable(a_terminations=[interface], b_terminations=[kept[0]])
+        cable.save()
+
+        site_group.delete()
+
+        self.assertFalse(CircuitTermination.objects.filter(pk=direct.pk).exists())
+        for termination in kept:
+            termination.refresh_from_db()
+            self.assertIsNone(termination._site_group_id)
+        self.assertEqual(kept[0].cable, cable)
+        self.assertTrue(kept[0].cable_terminations.exists())
+
+    @tag('regression')  # Ref: #23185
+    def test_deleting_region_keeps_terminations_of_its_sites(self):
+        """Deleting a Region and its child deletes only the terminations attached to them directly."""
+        parent = Region.objects.create(name='Region 1', slug='region-1')
+        region = Region.objects.create(name='Region 2', slug='region-2', parent=parent)
+        site = Site.objects.create(name='Site 3', slug='site-3', region=region)
+        location = Location.objects.create(name='Location 1', slug='location-1', site=site)
+        direct = (
+            CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=parent),
+            CircuitTermination.objects.create(circuit=self.circuits[0], term_side='Z', termination=region),
+        )
+        kept = (
+            CircuitTermination.objects.create(circuit=self.circuits[1], term_side='A', termination=site),
+            CircuitTermination.objects.create(circuit=self.circuits[1], term_side='Z', termination=location),
+        )
+
+        parent.delete()
+
+        self.assertFalse(CircuitTermination.objects.filter(pk__in=[t.pk for t in direct]).exists())
+        for termination in kept:
+            termination.refresh_from_db()
+            self.assertIsNone(termination._region_id)
+
 
 class CircuitTerminationDenormalizationTriggerTestCase(TestCase):
     """
@@ -570,6 +618,23 @@ class CircuitTerminationChangeLoggingTestCase(TestCase):
         )
         self.assertLess(changes[0].pk, termination_delete.pk)
 
+    @tag('regression')  # Ref: #23185
+    def test_site_group_deletion_keeps_terminations_of_its_sites(self):
+        """In a request, deleting a SiteGroup deletes and logs the termination attached to it and keeps the other."""
+        site_group = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1')
+        site = Site.objects.create(name='Site 3', slug='site-3', group=site_group)
+        direct = CircuitTermination.objects.create(circuit=self.circuits[0], term_side='A', termination=site_group)
+        kept = CircuitTermination.objects.create(circuit=self.circuits[0], term_side='Z', termination=site)
+        interface = Interface.objects.create(device=create_test_device('Device 1'), name='Interface 1')
+        Cable(a_terminations=[interface], b_terminations=[kept]).save()
+
+        self._tracked(site_group.delete)
+
+        self._termination_change(direct.pk, ObjectChangeActionChoices.ACTION_DELETE)
+        kept.refresh_from_db()
+        self.assertIsNone(kept._site_group_id)
+        self.assertTrue(kept.cable_terminations.exists())
+
     def test_circuit_deletion_records_no_pointer_update(self):
         self._tracked(lambda: CircuitTermination.objects.create(
             circuit=self.circuits[0], term_side='A', termination=self.sites[0],

+ 0 - 1
netbox/core/filtersets.py

@@ -37,7 +37,6 @@ class DataSourceFilterSet(PrimaryModelFilterSet):
     sync_interval = django_filters.MultipleChoiceFilter(
         choices=JobIntervalChoices,
         distinct=False,
-        null_value=None
     )
 
     class Meta:

+ 1 - 1
netbox/core/models/data.py

@@ -273,7 +273,7 @@ class DataSource(JobsMixin, PrimaryModel):
         paths = set()
 
         for path, dir_names, file_names in os.walk(root):
-            path = path.split(root)[1].lstrip('/')  # Strip root path
+            path = path.removeprefix(root).lstrip('/')  # Strip root path
             if path.startswith('.'):
                 continue
             for file_name in file_names:

+ 46 - 25
netbox/core/models/jobs.py

@@ -1,7 +1,7 @@
 import logging
 import uuid
 from dataclasses import asdict
-from functools import partial
+from functools import cached_property, partial
 
 import django_rq
 from django.conf import settings
@@ -10,7 +10,7 @@ from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.serializers.json import DjangoJSONEncoder
 from django.core.validators import MinValueValidator
-from django.db import models, transaction
+from django.db import models, router, transaction
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.translation import gettext as _
@@ -21,6 +21,7 @@ from core.dataclasses import JobLogEntry
 from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED
 from core.models import ObjectType
 from core.signals import job_end, job_start
+from core.utils import get_job_result_url
 from extras.models import Notification
 from netbox.models.features import has_feature
 from utilities.json import JobLogDecoder
@@ -155,13 +156,12 @@ class Job(models.Model):
         return self.name
 
     def get_absolute_url(self):
-        # TODO: Employ dynamic registration
-        if self.object_type:
-            if self.object_type.model == 'reportmodule':
-                return reverse('extras:report_result', kwargs={'job_pk': self.pk})
-            if self.object_type.model == 'scriptmodule':
-                return reverse('extras:script_result', kwargs={'job_pk': self.pk})
-        return reverse('core:job', args=[self.pk])
+        return self._absolute_url
+
+    @cached_property
+    def _absolute_url(self):
+        # Cached because job tables link multiple columns, and a registered result URL function may query the database
+        return get_job_result_url(self) or reverse('core:job', args=[self.pk])
 
     def get_status_color(self):
         return JobStatusChoices.colors.get(self.status)
@@ -230,23 +230,31 @@ class Job(models.Model):
 
     def start(self):
         """
-        Record the job's start time and update its status to "running."
+        Claim a pending or scheduled job for execution. Return True if claimed, otherwise False.
         """
-        if self.started is not None:
-            return
-
-        # Start the job
-        self.started = timezone.now()
+        using = router.db_for_write(self.__class__, instance=self)
+        started = timezone.now()
+        claimed = Job.objects.using(using).filter(
+            pk=self.pk,
+            job_id=self.job_id,
+            status__in=(JobStatusChoices.STATUS_PENDING, JobStatusChoices.STATUS_SCHEDULED),
+            started__isnull=True,
+        ).update(status=JobStatusChoices.STATUS_RUNNING, started=started)
+        if not claimed:
+            return False
+
+        self.started = started
         self.status = JobStatusChoices.STATUS_RUNNING
-        self.save()
 
         # Send signal
         job_start.send(self)
+        return True
     start.alters_data = True
 
-    def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None):
+    def terminate(self, status=JobStatusChoices.STATUS_COMPLETED, error=None, *, dispatch=True):
         """
-        Mark the job as completed, optionally specifying a particular termination status.
+        Mark the job as completed, optionally specifying a particular termination status. With dispatch=False, the
+        owner's notification and job_end are left to a later _dispatch_termination() call.
         """
         if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
             raise ValueError(
@@ -264,21 +272,34 @@ class Job(models.Model):
             self.execution_time = self.completed - self.started
         self.save()
 
-        # Notify the user (if any) of completion
+        if dispatch:
+            self._dispatch_termination()
+    terminate.alters_data = True
+
+    def _dispatch_termination(self):
+        """
+        Notify the owner (if any) of the job's outcome and send job_end.
+        """
         if self.user and self.notifications != JobNotificationChoices.NOTIFICATION_NEVER:
             if (
                 self.notifications == JobNotificationChoices.NOTIFICATION_ALWAYS or
-                status != JobStatusChoices.STATUS_COMPLETED
+                self.status != JobStatusChoices.STATUS_COMPLETED
             ):
-                Notification(
+                # A Job may be terminated more than once, so update any earlier notification in place
+                Notification.objects.update_or_create(
+                    object_type=ObjectType.objects.get_for_model(self),
+                    object_id=self.pk,
                     user=self.user,
-                    object=self,
-                    event_type=self.get_event_type(),
-                ).save()
+                    defaults={
+                        'event_type': self.get_event_type(),
+                        'object_repr': Notification.get_object_repr(self),
+                        'created': self.completed,
+                        'read': None,
+                    },
+                )
 
         # Send signal
         job_end.send(self)
-    terminate.alters_data = True
 
     def log(self, record: logging.LogRecord):
         """

+ 1 - 1
netbox/core/tables/template_code.py

@@ -42,7 +42,7 @@ DATA_SOURCE_SYNC_BUTTON = """
 {% load i18n %}
 {% if perms.core.sync_datasource %}
     {% if record.ready_for_sync %}
-        <button class="btn btn-primary btn-sm" type="submit" formaction="{% url 'core:datasource_sync' pk=record.pk %}?return_url={{ request.get_full_path|urlencode }}" formmethod="post">
+        <button class="btn btn-primary btn-sm" type="submit" formaction="{% url 'core:datasource_sync' pk=record.pk %}?return_url={{ request.get_full_path|urlencode }}" formmethod="post" data-disable-on-submit>
             <i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync" %}
         </button>
     {% else %}

+ 4 - 2
netbox/core/tests/test_api.py

@@ -521,7 +521,8 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         # Re-enqueue the failed job and check that its status has been reset
         self.user.is_superuser = True
         self.user.save()
-        response = self.client.post(url, **self.header)
+        with self.captureOnCommitCallbacks(execute=True):
+            response = self.client.post(url, **self.header)
         self.assertEqual(response.status_code, 200)
         job = RQ_Job.fetch(job.id, queue.connection)
         self.assertFalse(job.is_failed)
@@ -547,7 +548,8 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         # Force-enqueue the deferred job
         self.user.is_superuser = True
         self.user.save()
-        response = self.client.post(url, **self.header)
+        with self.captureOnCommitCallbacks(execute=True):
+            response = self.client.post(url, **self.header)
         self.assertEqual(response.status_code, 200)
 
         # Check that job's status is updated correctly

+ 14 - 1
netbox/core/tests/test_filtersets.py

@@ -46,7 +46,6 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
                 source_url='https://example.com/git/source3',
                 status=DataSourceStatusChoices.COMPLETED,
                 enabled=False,
-                sync_interval=JobIntervalChoices.INTERVAL_WEEKLY
             ),
         )
         DataSource.objects.bulk_create(data_sources)
@@ -81,6 +80,20 @@ class DataSourceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, JobIntervalChoices.INTERVAL_DAILY]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # The null choice value matches data sources without a sync interval
+        for params, count in (
+            ({'sync_interval': ['null']}, 1),
+            ({'sync_interval__n': ['null']}, 2),
+            ({'sync_interval': [JobIntervalChoices.INTERVAL_HOURLY, 'null']}, 2),
+        ):
+            filterset = self.filterset(params, self.queryset)
+            self.assertTrue(filterset.is_valid(), msg=params)
+            self.assertEqual(filterset.qs.count(), count, msg=params)
+
+        # Reject invalid filter values
+        for params in ({'sync_interval': ['None']}, {'sync_interval__n': ['None']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
 
 class DataFileTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = DataFile.objects.all()

+ 38 - 2
netbox/core/tests/test_jobs.py

@@ -4,12 +4,13 @@ from unittest.mock import MagicMock, patch
 
 import requests
 from django.conf import settings
-from django.test import TestCase, override_settings
+from django.test import TestCase, override_settings, tag
 from django.utils import timezone
 
-from core.choices import DataSourceStatusChoices
+from core.choices import DataSourceStatusChoices, JobStatusChoices
 from core.jobs import SyncDataSourceJob, SystemHousekeepingJob
 from core.models import DataFile, DataSource, Job
+from core.signals import job_end
 
 
 def _make_runner(cls, **job_attrs):
@@ -43,6 +44,28 @@ class SyncDataSourceJobTestCase(TestCase):
             source_url='/tmp/test',
         )
 
+    def handle_recurring_sync(self, **changes):
+        """Handle a recurring sync, saving its data source with `changes` as the job ends, and list waiting jobs."""
+        self.datasource.sync_interval = 60
+        self.datasource.save()
+        job = SyncDataSourceJob.get_jobs(self.datasource).get()
+
+        def save_datasource(sender, **kwargs):
+            datasource = DataSource.objects.get(pk=self.datasource.pk)
+            for name, value in changes.items():
+                setattr(datasource, name, value)
+            datasource.save()
+
+        job_end.connect(save_datasource, sender=job)
+        try:
+            with patch.object(SyncDataSourceJob, 'run'):
+                SyncDataSourceJob.handle(job)
+        finally:
+            job_end.disconnect(save_datasource, sender=job)
+
+        waiting = SyncDataSourceJob.get_jobs(self.datasource).filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES)
+        return list(waiting.values_list('status', 'interval'))
+
     def test_enqueue_sets_datasource_status_to_queued(self):
         job = MagicMock()
         job.object = self.datasource
@@ -126,6 +149,19 @@ class SyncDataSourceJobTestCase(TestCase):
         with self.assertRaises(DataSource.DoesNotExist):
             runner.run()
 
+    @tag('regression')  # Ref: #23535
+    def test_handle_keeps_one_schedule_when_saved_on_end(self):
+        """Saving the data source unchanged as its sync ends keeps one schedule."""
+        self.assertEqual(self.handle_recurring_sync(), [(JobStatusChoices.STATUS_SCHEDULED, 60)])
+
+    def test_handle_replaces_schedule_when_interval_changed_on_end(self):
+        """Changing the sync interval as the sync ends replaces the schedule."""
+        self.assertEqual(self.handle_recurring_sync(sync_interval=720), [(JobStatusChoices.STATUS_PENDING, 720)])
+
+    def test_handle_removes_schedule_when_disabled_on_end(self):
+        """Disabling the data source as its sync ends leaves no schedule."""
+        self.assertEqual(self.handle_recurring_sync(enabled=False), [])
+
 
 class SystemHousekeepingRunTestCase(HousekeepingRunnerMixin, TestCase):
     SUBMETHODS = (

+ 169 - 2
netbox/core/tests/test_models.py

@@ -12,15 +12,17 @@ from django.core.management import call_command
 from django.db import IntegrityError, connection
 from django.test import TestCase, override_settings
 from django.test.utils import CaptureQueriesContext
+from django.urls import reverse
 from django.utils import timezone
 
 from core.choices import DataSourceStatusChoices, JobNotificationChoices, JobStatusChoices, ObjectChangeActionChoices
 from core.data_backends import LocalBackend
+from core.events import JOB_COMPLETED, JOB_FAILED
 from core.exceptions import SyncError
 from core.models import AutoSyncRecord, DataFile, DataSource, Job, ObjectType
 from core.signals import post_sync
 from dcim.models import Device, Location, Site
-from extras.models import ConfigTemplate, Notification
+from extras.models import ConfigTemplate, Notification, Script
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 from users.models import User
 from utilities.querysets import RestrictedQuerySet
@@ -275,6 +277,21 @@ class DataSourceTestCase(TestCase):
 
         self.assertEqual(dict(self.datasource.datafiles.values_list('path', 'data')), {'a.txt': b'new\n'})
 
+    def test_sync_keeps_paths_repeating_source_root(self):
+        """A file below a directory repeating the source root syncs under its full path."""
+        # Repeat the whole root string below it, as /scripts/scripts_old does for /scripts
+        nested_path = f'{self.root}_old/legacy.py'.lstrip('/')
+        (self.root / nested_path).parent.mkdir(parents=True)
+        (self.root / nested_path).write_text('nested\n')
+        (self.root / 'legacy.py').write_text('root\n')
+
+        self.datasource.sync()
+
+        self.assertEqual(
+            dict(self.datasource.datafiles.values_list('path', 'data')),
+            {'a.txt': b'new\n', 'b.txt': b'new\n', 'legacy.py': b'root\n', nested_path: b'nested\n'},
+        )
+
 
 class ObjectTypeTestCase(TestCase):
 
@@ -355,7 +372,7 @@ class ObjectTypeTestCase(TestCase):
 
 class JobTestCase(TestCase):
 
-    def _make_job(self, user, notifications):
+    def _make_job(self, user, notifications, **kwargs):
         """
         Create and return a persisted Job with the given user and notifications setting.
         """
@@ -365,6 +382,7 @@ class JobTestCase(TestCase):
             user=user,
             notifications=notifications,
             status=JobStatusChoices.STATUS_RUNNING,
+            **kwargs,
         )
 
     @patch('core.models.jobs.django_rq.get_queue')
@@ -399,6 +417,107 @@ class JobTestCase(TestCase):
         mock_queue.fetch_job.assert_called_with(str(job.job_id))
         mock_rq_job.cancel.assert_called_once()
 
+    @patch('core.models.jobs.job_start')
+    def test_start_waiting_job(self, mock_job_start):
+        """A pending or scheduled Job is claimed and marked running."""
+        for status in (JobStatusChoices.STATUS_PENDING, JobStatusChoices.STATUS_SCHEDULED):
+            with self.subTest(status=status):
+                job = Job.objects.create(name='Test Job', job_id=uuid.uuid4(), status=status)
+
+                self.assertTrue(job.start())
+
+                started = job.started
+                self.assertIsNotNone(started)
+                self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+                job.refresh_from_db()
+                self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+                self.assertEqual(job.started, started)
+                mock_job_start.send.assert_called_once_with(job)
+                mock_job_start.reset_mock()
+
+    @patch('core.models.jobs.job_start')
+    def test_start_deleted_job(self, mock_job_start):
+        """A deleted Job is neither claimed nor re-created."""
+        job = Job.objects.create(name='Test Job', job_id=uuid.uuid4())
+        Job.objects.filter(pk=job.pk).delete()
+
+        self.assertFalse(job.start())
+
+        self.assertFalse(Job.objects.filter(pk=job.pk).exists())
+        self.assertIsNone(job.started)
+        mock_job_start.send.assert_not_called()
+
+    @patch('core.models.jobs.job_start')
+    def test_start_non_waiting_job(self, mock_job_start):
+        """A running or terminated Job is not claimed and keeps its status."""
+        for status in (JobStatusChoices.STATUS_RUNNING, *JobStatusChoices.TERMINAL_STATE_CHOICES):
+            with self.subTest(status=status):
+                job = Job.objects.create(name='Test Job', job_id=uuid.uuid4())
+                Job.objects.filter(pk=job.pk).update(status=status)
+
+                self.assertFalse(job.start())
+
+                job.refresh_from_db()
+                self.assertEqual(job.status, status)
+                self.assertIsNone(job.started)
+                mock_job_start.send.assert_not_called()
+
+    @patch('core.models.jobs.job_start')
+    def test_start_already_started_job(self, mock_job_start):
+        """A waiting Job that already has a start time is not claimed."""
+        job = Job.objects.create(name='Test Job', job_id=uuid.uuid4())
+        started = timezone.now()
+        Job.objects.filter(pk=job.pk).update(started=started)
+
+        self.assertFalse(job.start())
+
+        job.refresh_from_db()
+        self.assertEqual(job.started, started)
+        self.assertEqual(job.status, JobStatusChoices.STATUS_PENDING)
+        mock_job_start.send.assert_not_called()
+
+    @patch('core.models.jobs.job_start')
+    def test_start_once(self, mock_job_start):
+        """Two copies of one Job claim it only once."""
+        job = Job.objects.create(name='Test Job', job_id=uuid.uuid4())
+        stale_job = Job.objects.get(pk=job.pk)
+
+        self.assertTrue(job.start())
+        started = job.started
+        self.assertFalse(stale_job.start())
+        self.assertFalse(job.start())
+
+        job.refresh_from_db()
+        self.assertEqual(job.started, started)
+        mock_job_start.send.assert_called_once_with(job)
+
+    @patch('core.models.jobs.job_start')
+    def test_start_job_id_mismatch(self, mock_job_start):
+        """A stale copy cannot claim a Job whose task ID has changed."""
+        job = Job.objects.create(name='Test Job', job_id=uuid.uuid4())
+        stale_job = Job.objects.get(pk=job.pk)
+        Job.objects.filter(pk=job.pk).update(job_id=uuid.uuid4())
+
+        self.assertFalse(stale_job.start())
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_PENDING)
+        self.assertIsNone(job.started)
+        mock_job_start.send.assert_not_called()
+
+    @patch('core.models.jobs.job_start')
+    def test_start_preserves_other_fields(self, mock_job_start):
+        """Claiming a Job writes only its status and start time."""
+        job = Job.objects.create(name='Test Job', job_id=uuid.uuid4())
+        stale_job = Job.objects.get(pk=job.pk)
+        Job.objects.filter(pk=job.pk).update(name='Renamed')
+
+        self.assertTrue(stale_job.start())
+
+        job.refresh_from_db()
+        self.assertEqual(job.name, 'Renamed')
+        self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+
     @patch('core.models.jobs.job_end')
     def test_terminate_notification_always(self, mock_job_end):
         """
@@ -471,6 +590,46 @@ class JobTestCase(TestCase):
                     msg=f"Expected no notification for status={status} with notifications=never",
                 )
 
+    @patch('core.models.jobs.job_end')
+    def test_terminate_updates_existing_notification(self, mock_job_end):
+        """Terminating a Job re-issues its owner's existing notification as unread."""
+        user = User.objects.create_user(username='notification-existing')
+        datasource = DataSource.objects.create(name='Data Source 1', type='local')
+        job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ALWAYS, object=datasource)
+        previous_time = timezone.now() - timedelta(days=1)
+        notification = Notification.objects.create(user=user, object=job, event_type=JOB_FAILED, read=previous_time)
+        Notification.objects.filter(pk=notification.pk).update(created=previous_time)
+
+        job.name = 'Renamed Job'
+        job.terminate()
+
+        notification.refresh_from_db()
+        self.assertEqual(notification.event_type, JOB_COMPLETED)
+        self.assertEqual(notification.object_repr, 'Renamed Job')
+        self.assertEqual(notification.created, job.completed)
+        self.assertIsNone(notification.read)
+        mock_job_end.send.assert_called_once_with(job)
+
+    @patch('core.models.jobs.job_end')
+    def test_terminate_without_dispatch(self, mock_job_end):
+        """Terminating without dispatch saves the outcome and leaves the notification and job_end for later."""
+        user = User.objects.create_user(username='notification-deferred')
+        job = self._make_job(user, JobNotificationChoices.NOTIFICATION_ON_FAILURE)
+
+        job.terminate(status=JobStatusChoices.STATUS_FAILED, dispatch=False)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
+        self.assertIsNotNone(job.completed)
+        self.assertFalse(Notification.objects.filter(user=user, object_id=job.pk).exists())
+        mock_job_end.send.assert_not_called()
+
+        job._dispatch_termination()
+
+        notification = Notification.objects.get(user=user, object_id=job.pk)
+        self.assertEqual(notification.event_type, JOB_FAILED)
+        mock_job_end.send.assert_called_once_with(job)
+
     @patch('core.models.jobs.job_end')
     def test_execution_time_set_on_terminate(self, mock_job_end):
         """
@@ -505,3 +664,11 @@ class JobTestCase(TestCase):
         job.terminate(status=JobStatusChoices.STATUS_COMPLETED)
 
         self.assertIsNone(job.execution_time)
+
+    def test_get_absolute_url(self):
+        job = Job(pk=123, name='Test Job')
+        self.assertEqual(job.get_absolute_url(), reverse('core:job', args=[123]))
+
+        # Jobs for custom scripts link to the script result view
+        job = Job(pk=123, name='Test Job', object_type=ObjectType.objects.get_for_model(Script), object_id=1)
+        self.assertEqual(job.get_absolute_url(), reverse('extras:script_result', kwargs={'job_pk': 123}))

+ 126 - 0
netbox/core/tests/test_utils.py

@@ -0,0 +1,126 @@
+from unittest.mock import Mock, patch
+
+from django.contrib.contenttypes.models import ContentType
+from django.core.exceptions import ImproperlyConfigured
+from django.test import TestCase
+from django.urls import NoReverseMatch, reverse
+
+from core.models import DataSource, Job, ObjectType
+from core.utils import register_job_result_url
+from dcim.models import Site
+from netbox.registry import registry
+
+
+class JobResultURLTestCase(TestCase):
+
+    def setUp(self):
+        patcher = patch.dict(registry, {'job_result_urls': {}})
+        patcher.start()
+        self.addCleanup(patcher.stop)
+
+    @staticmethod
+    def get_job(model=None, name='Test Job'):
+        object_type = ObjectType.objects.get_for_model(model) if model else None
+        return Job(pk=123, name=name, object_type=object_type, object_id=1 if model else None)
+
+    def test_register_job_result_url(self):
+        def func(job):
+            return None
+
+        self.assertIs(register_job_result_url(DataSource)(func), func)
+        self.assertIs(register_job_result_url(DataSource, job_name='Sync')(func), func)
+        self.assertIs(register_job_result_url('dcim.Site', raise_exceptions=True)(func), func)
+        self.assertEqual(registry['job_result_urls'], {
+            ('core.datasource', None): (func, False),
+            ('core.datasource', 'Sync'): (func, False),
+            ('dcim.site', None): (func, True),
+        })
+
+    def test_register_duplicate(self):
+        register_job_result_url(DataSource, job_name='Sync')(lambda job: None)
+        with self.assertRaises(ImproperlyConfigured):
+            register_job_result_url('core.datasource', job_name='Sync')(lambda job: None)
+
+    def test_register_invalid_label(self):
+        for label in ('datasource', 'core.datasource.foo', 'core.', '.datasource', '.'):
+            with self.subTest(label=label), self.assertRaises(ImproperlyConfigured):
+                register_job_result_url(label)
+
+    def test_no_object(self):
+        register_job_result_url(DataSource)(lambda job: '/foo/')
+
+        self.assertEqual(self.get_job().get_absolute_url(), reverse('core:job', args=[123]))
+
+    def test_no_registration_for_model(self):
+        register_job_result_url(DataSource)(lambda job: '/foo/')
+
+        self.assertEqual(self.get_job(Site).get_absolute_url(), reverse('core:job', args=[123]))
+
+    def test_model(self):
+        register_job_result_url(DataSource)(lambda job: f'/foo/{job.object_id}/')
+
+        self.assertEqual(self.get_job(DataSource).get_absolute_url(), '/foo/1/')
+
+    def test_job_name_takes_precedence(self):
+        register_job_result_url(DataSource)(lambda job: '/foo/')
+        register_job_result_url(DataSource, job_name='Sync')(lambda job: '/bar/')
+
+        self.assertEqual(self.get_job(DataSource, name='Sync').get_absolute_url(), '/bar/')
+        self.assertEqual(self.get_job(DataSource, name='Other').get_absolute_url(), '/foo/')
+
+    def test_declined(self):
+        register_job_result_url(DataSource, job_name='Sync')(lambda job: None)
+
+        self.assertEqual(self.get_job(DataSource, name='Sync').get_absolute_url(), reverse('core:job', args=[123]))
+
+        # Declining defers to the function registered for the model alone
+        register_job_result_url(DataSource)(lambda job: '/foo/')
+        self.assertEqual(self.get_job(DataSource, name='Sync').get_absolute_url(), '/foo/')
+
+    def test_exception(self):
+        @register_job_result_url(DataSource)
+        def func(job):
+            return reverse('plugins:invalid')
+
+        with self.assertLogs('netbox.jobs', level='WARNING'):
+            self.assertEqual(self.get_job(DataSource).get_absolute_url(), reverse('core:job', args=[123]))
+
+    def test_unexpected_exception(self):
+        @register_job_result_url(DataSource, job_name='Sync')
+        def func(job):
+            raise AttributeError
+
+        with self.assertLogs('netbox.jobs', level='ERROR'):
+            self.assertEqual(self.get_job(DataSource, name='Sync').get_absolute_url(), reverse('core:job', args=[123]))
+
+        # A failing function defers to the function registered for the model alone
+        register_job_result_url(DataSource)(lambda job: '/foo/')
+        with self.assertLogs('netbox.jobs', level='ERROR'):
+            self.assertEqual(self.get_job(DataSource, name='Sync').get_absolute_url(), '/foo/')
+
+    def test_raise_exceptions(self):
+        func = Mock()
+        register_job_result_url(DataSource, raise_exceptions=True)(func)
+
+        for exception in (NoReverseMatch, AttributeError):
+            func.side_effect = exception
+            with self.subTest(exception=exception.__name__), self.assertRaises(exception):
+                self.get_job(DataSource).get_absolute_url()
+
+    def test_url_cached(self):
+        func = Mock(return_value='/foo/')
+        register_job_result_url(DataSource)(func)
+        job = self.get_job(DataSource)
+
+        self.assertEqual(job.get_absolute_url(), '/foo/')
+        self.assertEqual(job.get_absolute_url(), '/foo/')
+        func.assert_called_once_with(job)
+
+    def test_unresolvable_model(self):
+        register_job_result_url(DataSource)(lambda job: '/foo/')
+
+        # e.g. a model belonging to a plugin which has been uninstalled
+        object_type = ContentType.objects.create(app_label='uninstalled_plugin', model='foo')
+        job = Job(pk=123, name='Test Job', object_type=object_type, object_id=1)
+
+        self.assertEqual(job.get_absolute_url(), reverse('core:job', args=[123]))

+ 256 - 3
netbox/core/tests/test_views.py

@@ -2,25 +2,37 @@ import json
 import urllib.parse
 import uuid
 from datetime import UTC, datetime
+from unittest.mock import patch
 
 from django.contrib.contenttypes.models import ContentType
+from django.db import connection, transaction
+from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 from django.utils import timezone
 from django_rq import get_queue
 from django_rq.settings import get_queues_map
 from django_rq.workers import get_worker
+from rq.exceptions import InvalidJobOperation
+from rq.executions import Execution
 from rq.job import Job as RQ_Job
 from rq.job import JobStatus
 from rq.registry import DeferredJobRegistry, FailedJobRegistry, FinishedJobRegistry, StartedJobRegistry
 
-from core.choices import ObjectChangeActionChoices
+from core.choices import JobStatusChoices, ObjectChangeActionChoices
 from core.models import *
 from dcim.models import Site
+from netbox.jobs import JobRunner
 from users.models import ObjectPermission, User
 from utilities.testing import TestCase, ViewTestCases, create_tags, disable_logging
 from utilities.testing.mixins import RQQueueTestMixin
 
 
+class TestJobRunner(JobRunner):
+
+    def run(self, *args, **kwargs):
+        pass
+
+
 class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = DataSource
 
@@ -63,6 +75,31 @@ class DataSourceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'description': 'New description',
         }
 
+    def test_sync_form_submission_guard(self):
+        """The Sync form of a ready data source opts into the duplicate submission guard."""
+        self.add_permissions('core.view_datasource', 'core.sync_datasource')
+        datasource = DataSource.objects.first()
+        url = reverse('core:datasource_sync', kwargs={'pk': datasource.pk})
+
+        response = self.client.get(datasource.get_absolute_url())
+
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, f'<form action="{url}" method="post" data-disable-on-submit>')
+
+    def test_sync_buttons_submission_guard(self):
+        """Each ready data source's Sync button opts into the duplicate submission guard."""
+        self.add_permissions('core.view_datasource', 'core.sync_datasource')
+        list_url = reverse('core:datasource_list')
+
+        response = self.client.get(list_url)
+
+        self.assertHttpStatus(response, 200)
+        for name in ('Data Source 1', 'Data Source 2', 'Data Source 3'):
+            url = reverse('core:datasource_sync', kwargs={'pk': DataSource.objects.get(name=name).pk})
+            self.assertContains(
+                response, f'formaction="{url}?return_url={list_url}" formmethod="post" data-disable-on-submit>'
+            )
+
 
 class DataFileTestCase(
     ViewTestCases.GetObjectViewTestCase,
@@ -466,7 +503,8 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         self.assertTrue(job.is_failed)
 
         # Re-enqueue the failed job and check that its status has been reset
-        response = self.client.get(reverse('core:background_task_requeue', args=[job.id]))
+        with self.captureOnCommitCallbacks(execute=True):
+            response = self.client.get(reverse('core:background_task_requeue', args=[job.id]))
         self.assertEqual(response.status_code, 302)
         self.assertFalse(job.is_failed)
 
@@ -485,7 +523,8 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         self.assertIsNone(job.enqueued_at)
 
         # Force-enqueue the deferred job
-        response = self.client.get(reverse('core:background_task_enqueue', args=[job.id]))
+        with self.captureOnCommitCallbacks(execute=True):
+            response = self.client.get(reverse('core:background_task_enqueue', args=[job.id]))
         self.assertEqual(response.status_code, 302)
 
         # Check that job's status is updated correctly
@@ -493,6 +532,220 @@ class BackgroundTaskTestCase(RQQueueTestMixin, TestCase):
         self.assertEqual(job.get_status(), JobStatus.QUEUED)
         self.assertIsNotNone(job.enqueued_at)
 
+    def test_background_task_enqueue_reruns_ended_job(self):
+        """Enqueue runs the Job of a finished or stopped task again."""
+        queue = get_queue('default')
+        for rq_status in (JobStatus.FINISHED, JobStatus.STOPPED):
+            with self.subTest(rq_status=rq_status):
+                with self.captureOnCommitCallbacks(execute=True):
+                    job = TestJobRunner.enqueue()
+                rq_job = RQ_Job.fetch(str(job.job_id), connection=queue.connection)
+                if rq_status == JobStatus.FINISHED:
+                    # JobRunner records the exception as errored, while RQ marks the task finished
+                    with patch.object(TestJobRunner, 'run', side_effect=Exception('Simulated job error')):
+                        self.run_rq_jobs('default')
+                    self.assertEqual(Job.objects.get(pk=job.pk).status, JobStatusChoices.STATUS_ERRORED)
+                else:
+                    # Simulate a stopped RQ task whose NetBox Job is still marked running
+                    queue.remove(rq_job)
+                    rq_job.set_status(JobStatus.STOPPED)
+                    Job.objects.filter(pk=job.pk).update(
+                        status=JobStatusChoices.STATUS_RUNNING, started=timezone.now()
+                    )
+                self.assertEqual(rq_job.get_status(), rq_status)
+
+                with self.captureOnCommitCallbacks(execute=True):
+                    response = self.client.get(reverse('core:background_task_enqueue', args=[rq_job.id]))
+                self.assertEqual(response.status_code, 302)
+                with patch.object(TestJobRunner, 'run') as run:
+                    self.run_rq_jobs('default')
+
+                run.assert_called_once()
+                job.refresh_from_db()
+                self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+    def test_background_task_requeue_reruns_job(self):
+        """Requeue runs a Job again after its worker died."""
+        queue = get_queue('default')
+        with self.captureOnCommitCallbacks(execute=True):
+            job = TestJobRunner.enqueue()
+        rq_job = RQ_Job.fetch(str(job.job_id), connection=queue.connection)
+        # A worker that dies mid-run leaves the Job running and its task failed
+        queue.remove(rq_job)
+        FailedJobRegistry(queue.name, queue.connection).add(rq_job)
+        rq_job.set_status(JobStatus.FAILED)
+        Job.objects.filter(pk=job.pk).update(status=JobStatusChoices.STATUS_RUNNING, started=timezone.now())
+
+        with self.captureOnCommitCallbacks(execute=True):
+            response = self.client.get(reverse('core:background_task_requeue', args=[rq_job.id]))
+        self.assertEqual(response.status_code, 302)
+        with patch.object(TestJobRunner, 'run') as run:
+            self.run_rq_jobs('default')
+
+        run.assert_called_once()
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+    def test_background_task_enqueue_leaves_running_job(self):
+        """Enqueue of a task its worker still holds does not run the Job twice."""
+        queue = get_queue('default')
+        with self.captureOnCommitCallbacks(execute=True):
+            job = TestJobRunner.enqueue()
+        rq_job = RQ_Job.fetch(str(job.job_id), connection=queue.connection)
+        # The worker holding the task has claimed the Job
+        queue.remove(rq_job)
+        rq_job.set_status(JobStatus.STARTED)
+        started = timezone.now()
+        Job.objects.filter(pk=job.pk).update(status=JobStatusChoices.STATUS_RUNNING, started=started)
+
+        with self.captureOnCommitCallbacks(execute=True):
+            response = self.client.get(reverse('core:background_task_enqueue', args=[rq_job.id]))
+        self.assertEqual(response.status_code, 302)
+        with patch.object(TestJobRunner, 'run') as run, self.assertLogs('netbox.jobs', 'WARNING'):
+            self.run_rq_jobs('default')
+
+        run.assert_not_called()
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+        self.assertEqual(job.started, started)
+
+    def test_background_task_retry_keeps_claim_of_live_execution(self):
+        """Requeue and Enqueue do not reset a Job that a live execution still runs."""
+        queue = get_queue('default')
+        for action in ('background_task_enqueue', 'background_task_requeue'):
+            with self.subTest(action=action):
+                with self.captureOnCommitCallbacks(execute=True):
+                    job = TestJobRunner.enqueue()
+                rq_job = RQ_Job.fetch(str(job.job_id), connection=queue.connection)
+                # A worker still runs the task
+                queue.remove(rq_job)
+                with queue.connection.pipeline() as pipeline:
+                    Execution.create(rq_job, ttl=60, pipeline=pipeline)
+                    pipeline.execute()
+                if action == 'background_task_enqueue':
+                    # A refused second copy marks the shared task finished
+                    rq_job.set_status(JobStatus.FINISHED)
+                else:
+                    # An earlier Enqueue of the failed task left it in the failed registry
+                    FailedJobRegistry(queue.name, queue.connection).add(rq_job)
+                    rq_job.set_status(JobStatus.STARTED)
+                started = timezone.now()
+                Job.objects.filter(pk=job.pk).update(status=JobStatusChoices.STATUS_RUNNING, started=started)
+
+                with self.captureOnCommitCallbacks(execute=True):
+                    response = self.client.get(reverse(f'core:{action}', args=[rq_job.id]))
+                self.assertEqual(response.status_code, 302)
+                with patch.object(TestJobRunner, 'run') as run, self.assertLogs('netbox.jobs', 'WARNING'):
+                    self.run_rq_jobs('default')
+
+                run.assert_not_called()
+                job.refresh_from_db()
+                self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+                self.assertEqual(job.started, started)
+
+    def test_background_task_retry_locks_job(self):
+        """Requeue and Enqueue lock the Job before checking the task, and keep it locked through the reset."""
+        queue = get_queue('default')
+        contains = StartedJobRegistry.__contains__
+        for action in ('background_task_enqueue', 'background_task_requeue'):
+            with self.subTest(action=action):
+                with self.captureOnCommitCallbacks(execute=True):
+                    job = TestJobRunner.enqueue()
+                rq_job = RQ_Job.fetch(str(job.job_id), connection=queue.connection)
+                queue.remove(rq_job)
+                if action == 'background_task_enqueue':
+                    rq_job.set_status(JobStatus.FINISHED)
+                else:
+                    FailedJobRegistry(queue.name, queue.connection).add(rq_job)
+                    rq_job.set_status(JobStatus.FAILED)
+                checks = []
+
+                def check(registry, item):
+                    checks.append(len(queries))
+                    return contains(registry, item)
+
+                with (
+                    CaptureQueriesContext(connection) as queries,
+                    patch.object(StartedJobRegistry, '__contains__', autospec=True, side_effect=check),
+                    self.captureOnCommitCallbacks(execute=True),
+                ):
+                    self.client.get(reverse(f'core:{action}', args=[rq_job.id]))
+                sql = [query['sql'] for query in queries.captured_queries]
+
+                locks = [i for i, s in enumerate(sql) if 'FROM "core_job"' in s and s.endswith('FOR UPDATE')]
+                resets = [i for i, s in enumerate(sql) if s.startswith('UPDATE "core_job"')]
+                self.assertEqual((len(locks), len(checks), len(resets)), (1, 1, 1))
+                # The task is checked after the lock and before the reset
+                self.assertLess(locks[0], checks[0])
+                self.assertLessEqual(checks[0], resets[0])
+                # The helper acquires the row lock and resets the Job within one savepoint
+                self.assertTrue(sql[locks[0] - 1].startswith('SAVEPOINT '))
+                self.assertIn(f'RELEASE {sql[locks[0] - 1]}', sql[resets[0] + 1:])
+
+    def test_background_task_retry_publishes_on_commit(self):
+        """Requeue and Enqueue push the task only once the Job's reset commits, and not after a rollback."""
+        queue = get_queue('default')
+        for action in ('background_task_enqueue', 'background_task_requeue'):
+            with self.subTest(action=action):
+                with self.captureOnCommitCallbacks(execute=True):
+                    job = TestJobRunner.enqueue()
+                rq_job = RQ_Job.fetch(str(job.job_id), connection=queue.connection)
+                queue.remove(rq_job)
+                if action == 'background_task_enqueue':
+                    rq_job.set_status(JobStatus.FINISHED)
+                else:
+                    FailedJobRegistry(queue.name, queue.connection).add(rq_job)
+                    rq_job.set_status(JobStatus.FAILED)
+                Job.objects.filter(pk=job.pk).update(status=JobStatusChoices.STATUS_ERRORED)
+                url = reverse(f'core:{action}', args=[rq_job.id])
+
+                # A retry inside an outer transaction that rolls back
+                with self.captureOnCommitCallbacks() as callbacks, transaction.atomic():
+                    self.client.get(url)
+                    transaction.set_rollback(True)
+                self.assertEqual(callbacks, [])
+                self.assertNotIn(rq_job.id, queue.job_ids)
+                self.assertEqual(Job.objects.get(pk=job.pk).status, JobStatusChoices.STATUS_ERRORED)
+
+                with self.captureOnCommitCallbacks() as callbacks:
+                    self.client.get(url)
+                self.assertEqual(Job.objects.get(pk=job.pk).status, JobStatusChoices.STATUS_PENDING)
+                self.assertNotIn(rq_job.id, queue.job_ids)
+                self.assertEqual(len(callbacks), 1)
+                callbacks[0]()
+                self.assertIn(rq_job.id, queue.job_ids)
+
+    def test_background_task_requeue_finished_task(self):
+        """Requeue of a task outside the failed registry raises and leaves its Job untouched."""
+        with self.captureOnCommitCallbacks(execute=True):
+            job = TestJobRunner.enqueue()
+        self.run_rq_jobs('default')
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+        with disable_logging(), self.assertRaises(InvalidJobOperation), self.captureOnCommitCallbacks(execute=True):
+            self.client.get(reverse('core:background_task_requeue', args=[str(job.job_id)]))
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+    def test_background_task_requeue_custom_task_id(self):
+        """Requeue accepts a task whose id is not a UUID, and pushes it only on commit."""
+        queue = get_queue('default')
+        job = queue.enqueue(self.dummy_job_failing, job_id='custom-task-id')
+        with disable_logging():
+            self.run_rq_jobs('default')
+        self.assertTrue(job.is_failed)
+
+        with self.captureOnCommitCallbacks() as callbacks:
+            response = self.client.get(reverse('core:background_task_requeue', args=[job.id]))
+        self.assertEqual(response.status_code, 302)
+        self.assertNotIn(job.id, queue.job_ids)
+        self.assertEqual(len(callbacks), 1)
+        callbacks[0]()
+        self.assertIn(job.id, queue.job_ids)
+        self.assertFalse(job.is_failed)
+
     def test_background_task_stop(self):
         queue = get_queue('default')
 

+ 125 - 2
netbox/core/utils.py

@@ -1,5 +1,11 @@
-from django.db import DatabaseError, connection
+import logging
+import uuid
+from functools import partial
+
+from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
+from django.db import DatabaseError, connection, router, transaction
 from django.http import Http404
+from django.urls import NoReverseMatch
 from django.utils.translation import gettext_lazy as _
 from django_rq.queues import get_queue, get_queue_by_index, get_redis_connection
 from django_rq.settings import get_queues_list, get_queues_map
@@ -16,16 +22,23 @@ from rq.registry import (
     StartedJobRegistry,
 )
 
+from core.choices import JobStatusChoices
+from netbox.registry import registry
+
 __all__ = (
     'delete_rq_job',
     'enqueue_rq_job',
     'get_db_schema',
+    'get_job_result_url',
     'get_rq_jobs',
     'get_rq_jobs_from_status',
+    'register_job_result_url',
     'requeue_rq_job',
     'stop_rq_job',
 )
 
+logger = logging.getLogger('netbox.jobs')
+
 
 def get_rq_jobs():
     """
@@ -94,6 +107,34 @@ def delete_rq_job(job_id):
     job.delete()
 
 
+def _retry_rq_job(job, queue, ended, push):
+    """
+    Reset the matching NetBox Job so an explicitly retried RQ task can start it, then push the task.
+    """
+    from core.models import Job
+
+    using = router.db_for_write(Job)
+    with transaction.atomic(using=using):
+        try:
+            jobs = Job.objects.using(using).filter(job_id=uuid.UUID(job.id))
+        except ValueError:
+            # Job.job_id is a UUID, other task IDs cannot match
+            jobs = Job.objects.none()
+        # Lock the Job before checking the task, so no worker claims it in between
+        if jobs.select_for_update().exists() and ended() and job.id not in queue.started_job_registry:
+            jobs.update(
+                status=JobStatusChoices.STATUS_PENDING,
+                started=None,
+                completed=None,
+                execution_time=None,
+                error='',
+                data=None,
+                log_entries=[],
+            )
+        # A worker must see the reset, so push only once it commits
+        transaction.on_commit(push, using=using)
+
+
 def requeue_rq_job(job_id):
     """
     Requeue the specified RQ job.
@@ -107,7 +148,13 @@ def requeue_rq_job(job_id):
     queue_index = get_queues_map()[job.origin]
     queue = get_queue_by_index(queue_index)
 
-    requeue_job(job_id, connection=queue.connection, serializer=queue.serializer)
+    _retry_rq_job(
+        job,
+        queue,
+        # requeue_job() raises for a task outside the failed registry
+        ended=lambda: job.id in queue.failed_job_registry,
+        push=partial(requeue_job, job_id, connection=queue.connection, serializer=queue.serializer),
+    )
 
 
 def enqueue_rq_job(job_id):
@@ -123,6 +170,18 @@ def enqueue_rq_job(job_id):
     queue_index = get_queues_map()[job.origin]
     queue = get_queue_by_index(queue_index)
 
+    _retry_rq_job(
+        job,
+        queue,
+        ended=lambda: job.get_status() in (RQJobStatus.FINISHED, RQJobStatus.FAILED, RQJobStatus.STOPPED),
+        push=partial(_push_rq_job, queue, job),
+    )
+
+
+def _push_rq_job(queue, job):
+    """
+    Enqueue the RQ job regardless of its dependencies.
+    """
     try:
         # _enqueue_job is new in RQ 1.14, this is used to enqueue
         # job regardless of its dependencies
@@ -203,3 +262,67 @@ def get_db_schema():
     except DatabaseError:
         pass
     return db_schema
+
+
+def register_job_result_url(model, job_name=None, *, raise_exceptions=False):
+    """
+    Decorator for registering a function which returns the URL of the results page for background jobs associated
+    with objects of the given model (and optionally, having the given name). The model may be passed as a class or as
+    a string in the form "<app_label>.<model_name>".
+
+    The function must accept a Job instance and return either a URL, or None to defer to the next applicable function
+    (and ultimately to the generic job view). A function registered for a specific job name takes precedence over one
+    registered for the model alone.
+
+        @register_job_result_url(MyModel, job_name='My Job')
+        def my_job_result_url(job):
+            return reverse('plugins:myplugin:mymodel_results', args=[job.object_id])
+
+    If raise_exceptions is True, exceptions raised by the function propagate instead of being logged. NetBox's own
+    registrations set it, so that core bugs aren't masked.
+    """
+    if isinstance(model, str):
+        if len(parts := model.split('.')) != 2 or not all(parts):
+            raise ImproperlyConfigured(f"Invalid model label {model!r}: must be in the form <app_label>.<model_name>.")
+        label = model.lower()
+    else:
+        label = model._meta.label_lower
+    key = (label, job_name)
+
+    def _wrapper(func):
+        if key in registry['job_result_urls']:
+            raise ImproperlyConfigured(
+                f"A job result URL function is already registered for model {label} and job name {job_name!r}."
+            )
+        registry['job_result_urls'][key] = (func, raise_exceptions)
+        return func
+
+    return _wrapper
+
+
+def get_job_result_url(job):
+    """
+    Return the URL provided by the first applicable job result URL function registered for the given Job, if any.
+    """
+    if not job.object_type:
+        return None
+    model = f'{job.object_type.app_label}.{job.object_type.model}'
+    for key in ((model, job.name), (model, None)):
+        if not (registration := registry['job_result_urls'].get(key)):
+            continue
+        func, raise_exceptions = registration
+        try:
+            if url := func(job):
+                return url
+        except Exception as e:
+            # A failing function must not break the rendering of jobs lists or notifications.
+            if raise_exceptions:
+                raise
+            if isinstance(e, (NoReverseMatch, ObjectDoesNotExist)):
+                logger.warning(
+                    "Failed to resolve the result URL for job %s using %r: %s", job.pk, func, e,
+                    exc_info=logger.isEnabledFor(logging.DEBUG)
+                )
+            else:
+                logger.exception("Error resolving the result URL for job %s using %r", job.pk, func)
+    return None

+ 14 - 11
netbox/dcim/api/serializers_/devices.py

@@ -7,7 +7,7 @@ from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
 from dcim.choices import *
-from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS, MODULE_TOKEN
+from dcim.constants import MACADDRESS_ASSIGNMENT_MODELS
 from dcim.models import Device, DeviceBay, MACAddress, Module, VirtualDeviceContext
 from dcim.utils import get_module_bay_positions, resolve_module_placeholder
 from extras.api.serializers_.configtemplates import ConfigTemplateSerializer
@@ -286,16 +286,11 @@ class ModuleSerializer(PrimaryModelSerializer):
             }
 
             for template in getattr(module_type, templates_attr).all():
-                resolved_name = template.name
-                if MODULE_TOKEN in template.name:
-                    if not module_bay.position:
-                        raise serializers.ValidationError(
-                            _("Cannot install module with placeholder values in a module bay with no position defined.")
-                        )
-                    try:
-                        resolved_name = resolve_module_placeholder(template.name, positions)
-                    except ValueError as e:
-                        raise serializers.ValidationError(str(e))
+                try:
+                    resolved_name = resolve_module_placeholder(template.name, positions)
+                    resolve_module_placeholder(template.label, positions)
+                except ValueError as e:
+                    raise serializers.ValidationError(str(e)) from e
 
                 existing_item = installed_components.get(resolved_name)
 
@@ -315,6 +310,14 @@ class ModuleSerializer(PrimaryModelSerializer):
                         )
                     )
 
+        # Module bay names are unique per module, not per device, so only their placeholders are checked
+        for template in module_type.modulebaytemplates.all():
+            try:
+                for value in (template.name, template.label, template.position):
+                    resolve_module_placeholder(value, positions)
+            except ValueError as e:
+                raise serializers.ValidationError(str(e)) from e
+
         return data
 
     def create(self, validated_data):

+ 38 - 16
netbox/dcim/api/views.py

@@ -1,4 +1,5 @@
 from django.contrib.contenttypes.prefetch import GenericPrefetch
+from django.db.models import FETCH_PEERS, Prefetch
 from django.http import Http404, HttpResponse
 from django.shortcuts import get_object_or_404
 from drf_spectacular.types import OpenApiTypes
@@ -34,6 +35,25 @@ class DCIMRootView(APIRootView):
         return 'DCIM'
 
 
+class CableTerminationsPrefetch(Prefetch):
+    """
+    Prefetch cable terminations using the FETCH_PEERS fetch mode, which objects loaded by a to-many prefetch don't
+    inherit from the parent queryset.
+    """
+    def __init__(self, lookup='cable__terminations'):
+        super().__init__(lookup, queryset=self._get_queryset())
+
+    @staticmethod
+    def _get_queryset():
+        return CableTermination.objects.fetch_mode(FETCH_PEERS)
+
+    def get_current_querysets(self, level):
+        # Django adds routing hints to the queryset it's given, so build a new one rather than share this one
+        if super().get_current_querysets(level):
+            return [self._get_queryset()]
+        return None
+
+
 # Mixins
 
 class PathEndpointMixin:
@@ -459,32 +479,32 @@ class ModuleViewSet(NetBoxModelViewSet):
 #
 
 class ConsolePortViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = ConsolePort.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = ConsolePort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     serializer_class = serializers.ConsolePortSerializer
     filterset_class = filtersets.ConsolePortFilterSet
 
 
 class ConsoleServerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = ConsoleServerPort.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = ConsoleServerPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     serializer_class = serializers.ConsoleServerPortSerializer
     filterset_class = filtersets.ConsoleServerPortFilterSet
 
 
 class PowerPortViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = PowerPort.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = PowerPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     serializer_class = serializers.PowerPortSerializer
     filterset_class = filtersets.PowerPortFilterSet
 
 
 class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = PowerOutlet.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = PowerOutlet.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     serializer_class = serializers.PowerOutletSerializer
     filterset_class = filtersets.PowerOutletFilterSet
@@ -527,16 +547,16 @@ class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet):
 
 
 class FrontPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
-    queryset = FrontPort.objects.prefetch_related(
-        'cable__terminations',
+    queryset = FrontPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        CableTerminationsPrefetch(),
     )
     serializer_class = serializers.FrontPortSerializer
     filterset_class = filtersets.FrontPortFilterSet
 
 
 class RearPortViewSet(PassThroughPortMixin, NetBoxModelViewSet):
-    queryset = RearPort.objects.prefetch_related(
-        'cable__terminations',
+    queryset = RearPort.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        CableTerminationsPrefetch(),
     )
     serializer_class = serializers.RearPortSerializer
     filterset_class = filtersets.RearPortFilterSet
@@ -588,14 +608,16 @@ class MACAddressViewSet(NetBoxModelViewSet):
 #
 
 class CableViewSet(NetBoxModelViewSet):
-    queryset = Cable.objects.prefetch_related('terminations__termination')
+    queryset = Cable.objects.prefetch_related(
+        CableTerminationsPrefetch('terminations'),
+    )
     serializer_class = serializers.CableSerializer
     filterset_class = filtersets.CableFilterSet
 
 
 class CableTerminationViewSet(NetBoxReadOnlyModelViewSet):
     metadata_class = ContentTypeMetadata
-    queryset = CableTermination.objects.all()
+    queryset = CableTermination.objects.fetch_mode(FETCH_PEERS)
     serializer_class = serializers.CableTerminationSerializer
     filterset_class = filtersets.CableTerminationFilterSet
 
@@ -636,8 +658,8 @@ class PowerPanelViewSet(NetBoxModelViewSet):
 #
 
 class PowerFeedViewSet(PathEndpointMixin, NetBoxModelViewSet):
-    queryset = PowerFeed.objects.prefetch_related(
-        '_path', 'cable__terminations',
+    queryset = PowerFeed.objects.fetch_mode(FETCH_PEERS).prefetch_related(
+        '_path', CableTerminationsPrefetch(),
     )
     serializer_class = serializers.PowerFeedSerializer
     filterset_class = filtersets.PowerFeedFilterSet

+ 28 - 0
netbox/dcim/cable_profiles.py

@@ -282,6 +282,24 @@ class Trunk2C12PCableProfile(BaseCableProfile):
     b_connectors = a_connectors
 
 
+class Trunk3C8PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 8,
+        2: 8,
+        3: 8,
+    }
+    b_connectors = a_connectors
+
+
+class Trunk3C12PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 12,
+        2: 12,
+        3: 12,
+    }
+    b_connectors = a_connectors
+
+
 class Trunk4C1PCableProfile(BaseCableProfile):
     a_connectors = {
         1: 1,
@@ -332,6 +350,16 @@ class Trunk4C8PCableProfile(BaseCableProfile):
     b_connectors = a_connectors
 
 
+class Trunk4C12PCableProfile(BaseCableProfile):
+    a_connectors = {
+        1: 12,
+        2: 12,
+        3: 12,
+        4: 12,
+    }
+    b_connectors = a_connectors
+
+
 class Trunk8C1PCableProfile(BaseCableProfile):
     a_connectors = {
         1: 1,

+ 6 - 0
netbox/dcim/choices.py

@@ -1844,12 +1844,15 @@ class CableProfileChoices(ChoiceSet):
     TRUNK_2C6P = 'trunk-2c6p'
     TRUNK_2C8P = 'trunk-2c8p'
     TRUNK_2C12P = 'trunk-2c12p'
+    TRUNK_3C8P = 'trunk-3c8p'
+    TRUNK_3C12P = 'trunk-3c12p'
     TRUNK_4C1P = 'trunk-4c1p'
     TRUNK_4C2P = 'trunk-4c2p'
     TRUNK_4C4P = 'trunk-4c4p'
     TRUNK_4C4P_SHUFFLE = 'trunk-4c4p-shuffle'
     TRUNK_4C6P = 'trunk-4c6p'
     TRUNK_4C8P = 'trunk-4c8p'
+    TRUNK_4C12P = 'trunk-4c12p'
     TRUNK_8C1P = 'trunk-8c1p'
     TRUNK_8C4P = 'trunk-8c4p'
     TRUNK_12C1P = 'trunk-12c1p'
@@ -1884,12 +1887,15 @@ class CableProfileChoices(ChoiceSet):
                 Choice(TRUNK_2C6P, _('2C6P trunk')),
                 Choice(TRUNK_2C8P, _('2C8P trunk')),
                 Choice(TRUNK_2C12P, _('2C12P trunk')),
+                Choice(TRUNK_3C8P, _('3C8P trunk')),
+                Choice(TRUNK_3C12P, _('3C12P trunk')),
                 Choice(TRUNK_4C1P, _('4C1P trunk')),
                 Choice(TRUNK_4C2P, _('4C2P trunk')),
                 Choice(TRUNK_4C4P, _('4C4P trunk')),
                 Choice(TRUNK_4C4P_SHUFFLE, _('4C4P trunk (shuffle)')),
                 Choice(TRUNK_4C6P, _('4C6P trunk')),
                 Choice(TRUNK_4C8P, _('4C8P trunk')),
+                Choice(TRUNK_4C12P, _('4C12P trunk')),
                 Choice(TRUNK_8C1P, _('8C1P trunk')),
                 Choice(TRUNK_8C4P, _('8C4P trunk')),
                 Choice(TRUNK_12C1P, _('12C1P trunk')),

+ 15 - 13
netbox/dcim/filtersets.py

@@ -33,6 +33,7 @@ from utilities.filters import (
     MultiValueMACAddressFilter,
     MultiValueTimeZoneFilter,
     MultiValueWWNFilter,
+    NonUniqueModelMultipleChoiceFilter,
     NumericArrayFilter,
     TreeNodeMultipleChoiceFilter,
 )
@@ -286,7 +287,7 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         distinct=False,
         label=_('Parent location (ID)'),
     )
-    parent = django_filters.ModelMultipleChoiceFilter(
+    parent = NonUniqueModelMultipleChoiceFilter(
         field_name='parent__slug',
         queryset=Location.objects.all(),
         distinct=False,
@@ -1352,6 +1353,7 @@ class PlatformFilterSet(NestedGroupModelFilterSet):
     )
     available_for_device_type = django_filters.ModelChoiceFilter(
         queryset=DeviceType.objects.all(),
+        null_label=None,
         method='get_for_device_type'
     )
     config_template_id = django_filters.ModelMultipleChoiceFilter(
@@ -1846,7 +1848,7 @@ class ModuleFilterSet(PrimaryModelFilterSet):
         distinct=False,
         label=_('Location (ID)'),
     )
-    location = django_filters.ModelMultipleChoiceFilter(
+    location = NonUniqueModelMultipleChoiceFilter(
         field_name='device__location__slug',
         queryset=Location.objects.all(),
         distinct=False,
@@ -1859,7 +1861,7 @@ class ModuleFilterSet(PrimaryModelFilterSet):
         distinct=False,
         label=_('Rack (ID)'),
     )
-    rack = django_filters.ModelMultipleChoiceFilter(
+    rack = NonUniqueModelMultipleChoiceFilter(
         field_name='device__rack__name',
         queryset=Rack.objects.all(),
         distinct=False,
@@ -1871,7 +1873,7 @@ class ModuleFilterSet(PrimaryModelFilterSet):
         distinct=False,
         label=_('Device (ID)'),
     )
-    device = django_filters.ModelMultipleChoiceFilter(
+    device = NonUniqueModelMultipleChoiceFilter(
         field_name='device__name',
         queryset=Device.objects.all(),
         distinct=False,
@@ -1953,7 +1955,7 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         distinct=False,
         label=_('Location (ID)'),
     )
-    location = django_filters.ModelMultipleChoiceFilter(
+    location = NonUniqueModelMultipleChoiceFilter(
         field_name='_location__slug',
         queryset=Location.objects.all(),
         distinct=False,
@@ -1966,7 +1968,7 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         distinct=False,
         label=_('Rack (ID)'),
     )
-    rack = django_filters.ModelMultipleChoiceFilter(
+    rack = NonUniqueModelMultipleChoiceFilter(
         field_name='_rack__name',
         queryset=Rack.objects.all(),
         distinct=False,
@@ -1978,7 +1980,7 @@ class DeviceComponentFilterSet(OwnerFilterMixin, NetBoxModelFilterSet):
         distinct=False,
         label=_('Device (ID)'),
     )
-    device = django_filters.ModelMultipleChoiceFilter(
+    device = NonUniqueModelMultipleChoiceFilter(
         field_name='device__name',
         queryset=Device.objects.all(),
         distinct=False,
@@ -2255,7 +2257,7 @@ class MACAddressFilterSet(PrimaryModelFilterSet):
         field_name='pk',
         label=_('Virtual machine (ID)'),
     )
-    interface = django_filters.ModelMultipleChoiceFilter(
+    interface = NonUniqueModelMultipleChoiceFilter(
         field_name='interface__name',
         queryset=Interface.objects.all(),
         to_field_name='name',
@@ -2266,7 +2268,7 @@ class MACAddressFilterSet(PrimaryModelFilterSet):
         queryset=Interface.objects.all(),
         label=_('Interface (ID)'),
     )
-    vminterface = django_filters.ModelMultipleChoiceFilter(
+    vminterface = NonUniqueModelMultipleChoiceFilter(
         field_name='vminterface__name',
         queryset=VMInterface.objects.all(),
         to_field_name='name',
@@ -2516,13 +2518,13 @@ class InterfaceFilterSet(
         queryset=VirtualDeviceContext.objects.all(),
         label=_('Virtual Device Context')
     )
-    vdc_identifier = django_filters.ModelMultipleChoiceFilter(
+    vdc_identifier = NonUniqueModelMultipleChoiceFilter(
         field_name='vdcs__identifier',
         queryset=VirtualDeviceContext.objects.all(),
         to_field_name='identifier',
         label=_('Virtual Device Context (Identifier)')
     )
-    vdc = django_filters.ModelMultipleChoiceFilter(
+    vdc = NonUniqueModelMultipleChoiceFilter(
         field_name='vdcs__name',
         queryset=VirtualDeviceContext.objects.all(),
         to_field_name='name',
@@ -2674,7 +2676,7 @@ class DeviceBayFilterSet(DeviceComponentFilterSet):
         distinct=False,
         label=_('Installed device (ID)'),
     )
-    installed_device = django_filters.ModelMultipleChoiceFilter(
+    installed_device = NonUniqueModelMultipleChoiceFilter(
         field_name='installed_device__name',
         queryset=Device.objects.all(),
         distinct=False,
@@ -2761,7 +2763,7 @@ class VirtualChassisFilterSet(PrimaryModelFilterSet):
         distinct=False,
         label=_('Master (ID)'),
     )
-    master = django_filters.ModelMultipleChoiceFilter(
+    master = NonUniqueModelMultipleChoiceFilter(
         field_name='master__name',
         queryset=Device.objects.all(),
         distinct=False,

+ 22 - 13
netbox/dcim/forms/common.py

@@ -123,12 +123,18 @@ class ModuleCommonForm(forms.Form):
         if adopt_components:
             self.instance._adopt_components = True
 
-        # Bail out if we are not installing a new module or if we are not replicating components (or if
-        # validation has already failed)
-        if self.errors or self.instance.pk or not replicate_components:
+        # Bail out if we are not installing a new module (or if validation has already failed)
+        if self.errors or self.instance.pk:
             self.instance._disable_replication = True
             return
 
+        if not replicate_components:
+            self.instance._disable_replication = True
+
+        # Adoption also instantiates every template, so skip the checks only when neither operation is requested
+        if not replicate_components and not adopt_components:
+            return
+
         try:
             positions = get_module_bay_positions(module_bay)
         except ValueError as e:
@@ -152,16 +158,11 @@ class ModuleCommonForm(forms.Form):
 
             # Get the templates for the module type.
             for template in getattr(module_type, templates).all():
-                resolved_name = template.name
-                if MODULE_TOKEN in template.name:
-                    if not module_bay.position:
-                        raise forms.ValidationError(
-                            _("Cannot install module with placeholder values in a module bay with no position defined.")
-                        )
-                    try:
-                        resolved_name = resolve_module_placeholder(template.name, positions)
-                    except ValueError as e:
-                        raise forms.ValidationError(str(e))
+                try:
+                    resolved_name = resolve_module_placeholder(template.name, positions)
+                    resolve_module_placeholder(template.label, positions)
+                except ValueError as e:
+                    raise forms.ValidationError(str(e)) from e
 
                 existing_item = installed_components.get(resolved_name)
 
@@ -182,3 +183,11 @@ class ModuleCommonForm(forms.Form):
                             name=resolved_name
                         )
                     )
+
+        # Module bay names are unique per module, not per device, so only their placeholders are checked
+        for template in module_type.modulebaytemplates.all():
+            try:
+                for value in (template.name, template.label, template.position):
+                    resolve_module_placeholder(value, positions)
+            except ValueError as e:
+                raise forms.ValidationError(str(e)) from e

+ 8 - 3
netbox/dcim/forms/mixins.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 
 from dcim.constants import LOCATION_SCOPE_TYPES
 from dcim.utils import reconcile_port_mappings
+from netbox.context import current_request
 from utilities.forms import GenericObjectFormMixin
 from utilities.forms.fields import (
     CSVContentTypeField,
@@ -157,16 +158,20 @@ class FrontPortFormMixin(forms.Form):
 
     def _get_rear_port_choices(self, parent_filter, front_port):
         """
-        Return a list of choices representing each available rear port & position pair on the parent object (identified
-        by a Q filter), excluding those assigned to the specified instance.
+        Return rear port position choices for the parent, excluding positions assigned to other front ports.
         """
         occupied_rear_port_positions = [
             f'{mapping.rear_port_id}:{mapping.rear_port_position}'
             for mapping in self.port_mapping_model.objects.filter(parent_filter).exclude(front_port=front_port.pk)
         ]
 
+        # rear_ports is a plain choice field, so restrict_form_fields() cannot narrow it
+        rear_ports = self.rear_port_model.objects.filter(parent_filter)
+        if request := current_request.get():
+            rear_ports = rear_ports.restrict(request.user, 'view')
+
         choices = []
-        for rear_port in self.rear_port_model.objects.filter(parent_filter):
+        for rear_port in rear_ports:
             for i in range(1, rear_port.positions + 1):
                 pair_id = f'{rear_port.pk}:{i}'
                 if pair_id not in occupied_rear_port_positions:

+ 39 - 0
netbox/dcim/migrations/0245_consolidate_unique_constraints.py

@@ -1,6 +1,41 @@
 import django.db.models.functions.text
 from django.db import migrations, models
 
+# Installs that ran 0137 from v3.1-beta1 (see #7960) have auto-named unique
+# constraints in place of the named ones (#23260). Rename them so the
+# RemoveConstraint operations below find what they expect.
+RENAME_LEGACY_CONSTRAINTS = """
+DO $$
+DECLARE
+    r record;
+BEGIN
+    FOR r IN (
+        SELECT m.tbl, m.old_name, m.new_name
+        FROM (VALUES
+            ('dcim_location', 'dcim_location_site_id_parent_id_name_5c85730c_uniq', 'dcim_location_parent_name'),
+            ('dcim_location', 'dcim_location_site_id_parent_id_slug_4514cb1d_uniq', 'dcim_location_parent_slug'),
+            ('dcim_region', 'dcim_region_parent_id_name_2cd612fe_uniq', 'dcim_region_parent_name'),
+            ('dcim_region', 'dcim_region_parent_id_slug_132fcac2_uniq', 'dcim_region_parent_slug'),
+            ('dcim_sitegroup', 'dcim_sitegroup_parent_id_name_ccdbb50e_uniq', 'dcim_sitegroup_parent_name'),
+            ('dcim_sitegroup', 'dcim_sitegroup_parent_id_slug_e1b53f00_uniq', 'dcim_sitegroup_parent_slug')
+        ) AS m(tbl, old_name, new_name)
+        WHERE EXISTS (
+            SELECT 1 FROM pg_constraint
+            WHERE conrelid = to_regclass(m.tbl) AND conname = m.old_name
+        )
+    ) LOOP
+        IF EXISTS (
+            SELECT 1 FROM pg_constraint
+            WHERE conrelid = to_regclass(r.tbl) AND conname = r.new_name
+        ) THEN
+            EXECUTE format('ALTER TABLE %I DROP CONSTRAINT %I', r.tbl, r.old_name);
+        ELSE
+            EXECUTE format('ALTER TABLE %I RENAME CONSTRAINT %I TO %I', r.tbl, r.old_name, r.new_name);
+        END IF;
+    END LOOP;
+END $$;
+"""
+
 
 class Migration(migrations.Migration):
     dependencies = [
@@ -11,6 +46,10 @@ class Migration(migrations.Migration):
     ]
 
     operations = [
+        migrations.RunSQL(
+            sql=RENAME_LEGACY_CONSTRAINTS,
+            reverse_sql=migrations.RunSQL.noop,
+        ),
         migrations.RemoveConstraint(
             model_name='devicerole',
             name='dcim_devicerole_parent_name',

+ 3 - 0
netbox/dcim/models/cables.py

@@ -219,12 +219,15 @@ class Cable(PrimaryModel):
             CableProfileChoices.TRUNK_2C6P: cable_profiles.Trunk2C6PCableProfile,
             CableProfileChoices.TRUNK_2C8P: cable_profiles.Trunk2C8PCableProfile,
             CableProfileChoices.TRUNK_2C12P: cable_profiles.Trunk2C12PCableProfile,
+            CableProfileChoices.TRUNK_3C8P: cable_profiles.Trunk3C8PCableProfile,
+            CableProfileChoices.TRUNK_3C12P: cable_profiles.Trunk3C12PCableProfile,
             CableProfileChoices.TRUNK_4C1P: cable_profiles.Trunk4C1PCableProfile,
             CableProfileChoices.TRUNK_4C2P: cable_profiles.Trunk4C2PCableProfile,
             CableProfileChoices.TRUNK_4C4P: cable_profiles.Trunk4C4PCableProfile,
             CableProfileChoices.TRUNK_4C4P_SHUFFLE: cable_profiles.Trunk4C4PShuffleCableProfile,
             CableProfileChoices.TRUNK_4C6P: cable_profiles.Trunk4C6PCableProfile,
             CableProfileChoices.TRUNK_4C8P: cable_profiles.Trunk4C8PCableProfile,
+            CableProfileChoices.TRUNK_4C12P: cable_profiles.Trunk4C12PCableProfile,
             CableProfileChoices.TRUNK_8C1P: cable_profiles.Trunk8C1PCableProfile,
             CableProfileChoices.TRUNK_8C4P: cable_profiles.Trunk8C4PCableProfile,
             CableProfileChoices.TRUNK_12C1P: cable_profiles.Trunk12C1PCableProfile,

+ 1 - 1
netbox/dcim/models/device_component_templates.py

@@ -204,9 +204,9 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
             # Reached only from Module._save_new(); AbortRequest is what the view/viewset catches.
             try:
                 positions = get_module_bay_positions(module.module_bay)
+                value = resolve_module_placeholder(value, positions)
             except ValueError as e:
                 raise AbortRequest(str(e)) from e
-            value = resolve_module_placeholder(value, positions)
         if has_vc:
             resolved_device = (module.device if module else None) or device
             value = self._resolve_vc_position(value, resolved_device)

+ 31 - 0
netbox/dcim/models/device_components.py

@@ -1,6 +1,7 @@
 from functools import cached_property
 
 from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.fields import ArrayField
 from django.contrib.postgres.indexes import GistIndex
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
@@ -19,6 +20,7 @@ from dcim.models.mixins import (
     InterfaceValidationMixin,
     MaxFlowMixin,
 )
+from dcim.utils import rebuild_paths
 from netbox.choices import ColorChoices
 from netbox.models import NetBoxModel, OrganizationalModel
 from netbox.models.features import ChangeLoggingMixin
@@ -1425,6 +1427,35 @@ class Interface(
         if self.channels:
             self.clear_channel_cables()
 
+    def update_dependent_objects(self):
+        """
+        Reconcile a channel subinterface's mirrored cable attributes with its channelized parent, and retrace the
+        CablePaths which traverse this interface.
+        """
+        from dcim.models import CablePath, CableTermination
+
+        parent = None
+        if self.channel_id and self.parent_id:
+            parent = Interface.objects.filter(pk=self.parent_id, cable__isnull=False).select_related('cable').first()
+        if parent and parent.channels:
+            parent.cable.update_dependent_objects()
+        elif self.cable_id and not CableTermination.objects.filter(
+            termination_type=ContentType.objects.get_for_model(Interface), termination_id=self.pk
+        ).exists():
+            # A cable with no termination of this interface's own was mirrored from a former channelized parent
+            Interface.objects.filter(pk=self.pk).update(
+                cable=None, cable_end='', cable_connector=None, cable_positions=None
+            )
+            for cablepath in CablePath.objects.filter(_nodes__contains=self):
+                if self in cablepath.origins:
+                    cablepath.delete()
+        else:
+            return
+
+        # Retrace paths still traversing this interface from a former parent's cable
+        rebuild_paths([self])
+    update_dependent_objects.alters_data = True
+
     def propagate_channel_cables(self):
         """
         Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each

+ 12 - 7
netbox/dcim/models/devices.py

@@ -1368,14 +1368,19 @@ class VirtualChassis(PrimaryModel):
                 set(interfaces),
             )
 
-        # Clear vc_position and vc_priority on member devices BEFORE calling super().delete()
-        # This must be done here because on_delete=SET_NULL executes before pre_delete signal
-        for device in self.members.all():
-            device.vc_position = None
-            device.vc_priority = None
-            device.save()
+        # Core's pre_delete handler detaches each member (clearing virtual_chassis) and records that change. Clear the
+        # now-meaningless position and priority afterwards, as a second change which is valid on its own.
+        using = kwargs.get('using') or router.db_for_write(self.__class__, instance=self)
+        member_ids = list(self.members.values_list('pk', flat=True))
+        with transaction.atomic(using=using):
+            ret = super().delete(*args, **kwargs)
+            for device in Device.objects.using(using).filter(pk__in=member_ids):
+                device.snapshot()
+                device.vc_position = None
+                device.vc_priority = None
+                device.save(using=using)
 
-        return super().delete(*args, **kwargs)
+        return ret
 
 
 class VirtualDeviceContext(PrimaryModel):

+ 24 - 11
netbox/dcim/models/module_moves.py

@@ -307,7 +307,8 @@ class ModuleMovePlan:
     def _resolve(template, value, positions, device):
         """
         Resolve {module} and {vc_position} tokens in a template value against an explicit
-        position chain and device. Raises ValueError on a token-count mismatch.
+        position chain and device. Raises ValueError on a token-count mismatch or a missing
+        bay position.
         """
         if MODULE_TOKEN in value:
             value = resolve_module_placeholder(value, positions)
@@ -908,19 +909,30 @@ class ModuleMovePlan:
             self._send_post_saves(model, updated, fields)
 
     def _apply_port_mappings(self):
-        # Private model, no changelog or last_updated field; mirrors PortMapping.save()'s device derivation.
+        # Update each mapping's device after moving its ports
         if not self.cross_device:
             return
         moved_front_port_pks = [obj.pk for obj in self.components[FrontPort]]
         moved_rear_port_pks = [obj.pk for obj in self.components[RearPort]]
-        if moved_front_port_pks and moved_rear_port_pks:
-            chunked_update(
-                PortMapping.objects.filter(
-                    front_port_id__in=moved_front_port_pks,
-                    rear_port_id__in=moved_rear_port_pks,
-                ),
-                device_id=self.new_device_id,
-            )
+        if not (moved_front_port_pks and moved_rear_port_pks):
+            return
+        mappings = list(PortMapping.objects.using(router.db_for_write(PortMapping)).filter(
+            front_port_id__in=moved_front_port_pks,
+            rear_port_id__in=moved_rear_port_pks,
+        ).select_related('front_port', 'rear_port'))
+        if not mappings:
+            return
+        for mapping in mappings:
+            mapping.snapshot()
+        chunked_update(
+            PortMapping.objects.filter(pk__in=[mapping.pk for mapping in mappings]),
+            device_id=self.new_device_id,
+            last_updated=self._now,
+        )
+        for mapping in mappings:
+            mapping.device_id = self.new_device_id
+            mapping.last_updated = self._now
+        self._send_post_saves(PortMapping, mappings, ['device', 'last_updated'])
 
     @staticmethod
     def device_counters_by_model(device_model):
@@ -975,7 +987,8 @@ class ModuleMovePlan:
         for instance in instances:
             # Clear tracked counter state so the incremental counter receiver no-ops;
             # counters are recomputed explicitly for cross-device moves.
-            instance.tracker.clear()
+            if hasattr(instance, 'tracker'):
+                instance.tracker.clear()
             post_save.send(
                 sender=model,
                 instance=instance,

+ 20 - 4
netbox/dcim/models/modules.py

@@ -6,6 +6,7 @@ from django.conf import settings
 from django.core.exceptions import ValidationError
 from django.db import OperationalError, models, router, transaction
 from django.db.models.signals import post_save
+from django.utils import timezone
 from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
@@ -550,6 +551,8 @@ class Module(TrackingModelMixin, PrimaryModel):
         if disable_replication and not adopt_components:
             return
 
+        now = timezone.now()
+
         # Iterate all component types
         for templates, component_attribute, component_model in [
             ("consoleporttemplates", "consoleports", ConsolePort),
@@ -571,7 +574,7 @@ class Module(TrackingModelMixin, PrimaryModel):
                 component.name: component
                 for component in getattr(self.device, component_attribute).db_manager(using).filter(
                     module__isnull=True
-                )
+                ).prefetch_related('tags')
             }
 
             # Get the template for the module type.
@@ -584,8 +587,10 @@ class Module(TrackingModelMixin, PrimaryModel):
 
                     # Check if there's a component with the same name already
                     if existing_item:
-                        # Assign it to the module
+                        # Snapshot for the changelog, then assign it to the module
+                        existing_item.snapshot()
                         existing_item.module = self
+                        existing_item.last_updated = now
                         update_instances.append(existing_item)
                         continue
 
@@ -626,7 +631,7 @@ class Module(TrackingModelMixin, PrimaryModel):
                     update_fields=None
                 )
 
-            update_fields = ['module']
+            update_fields = ['module', 'last_updated']
             # ModuleBay.parent is derived from .module in ModuleBay.save(), and the
             # path/sort_path trigger only fires on parent_id/name changes. A bare
             # bulk_update(['module']) bypasses both, leaving the adopted bay rooted
@@ -635,11 +640,22 @@ class Module(TrackingModelMixin, PrimaryModel):
             if component_model is ModuleBay:
                 for instance in update_instances:
                     instance.parent = self.module_bay
-                update_fields = ['module', 'parent']
+                update_fields = ['module', 'parent', 'last_updated']
 
             component_model.objects.using(using).bulk_update(
                 update_instances, update_fields, batch_size=settings.BULK_UPDATE_CHUNK_SIZE
             )
+            # The triggers rewrote path/sort_path; refresh them so changelog snapshots aren't stale
+            if component_model is ModuleBay and update_instances:
+                refreshed = {
+                    row['pk']: row
+                    for row in ModuleBay.objects.using(using).filter(
+                        pk__in=[bay.pk for bay in update_instances]
+                    ).values('pk', 'path', 'sort_path')
+                }
+                for bay in update_instances:
+                    bay.path = refreshed[bay.pk]['path']
+                    bay.sort_path = refreshed[bay.pk]['sort_path']
             # Emit the post_save signal for each updated object
             for component in update_instances:
                 post_save.send(

+ 12 - 0
netbox/dcim/models/sites.py

@@ -55,6 +55,12 @@ class Region(ContactsMixin, NestedLtreeGroupModel):
         object_id_field='scope_id',
         related_query_name='region'
     )
+    # Deletion cascade only, so no related_query_name. circuit_terminations is the reverse of the cached _region.
+    direct_circuit_terminations = GenericRelation(
+        to='circuits.CircuitTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id'
+    )
 
     class Meta:
         ordering = ('sort_path',)
@@ -120,6 +126,12 @@ class SiteGroup(ContactsMixin, NestedLtreeGroupModel):
         object_id_field='scope_id',
         related_query_name='site_group'
     )
+    # Deletion cascade only, so no related_query_name. circuit_terminations is the reverse of the cached _site_group.
+    direct_circuit_terminations = GenericRelation(
+        to='circuits.CircuitTermination',
+        content_type_field='termination_type',
+        object_id_field='termination_id'
+    )
 
     class Meta:
         ordering = ('sort_path',)

+ 5 - 1
netbox/dcim/signals.py

@@ -233,6 +233,7 @@ def assign_virtualchassis_master(instance, created, **kwargs):
     """
     if created and instance.master:
         master = Device.objects.get(pk=instance.master.pk)
+        master.snapshot()
         master.virtual_chassis = instance
         master.vc_position = 1
         master.save()
@@ -307,10 +308,13 @@ def retrace_cable_paths(instance, **kwargs):
 
 
 @receiver((post_delete, post_save), sender=PortMapping)
-def update_passthrough_port_paths(instance, **kwargs):
+def update_passthrough_port_paths(instance, update_fields=None, **kwargs):
     """
     When a PortMapping is created or deleted, retrace any CablePaths which traverse its front and/or rear ports.
     """
+    # Moving a module to another device changes only these fields; its connections are unchanged
+    if update_fields and set(update_fields) <= {'device', 'last_updated'}:
+        return
     for cablepath in CablePath.objects.filter(
         Q(_nodes__contains=instance.front_port) | Q(_nodes__contains=instance.rear_port)
     ):

+ 2 - 2
netbox/dcim/tests/query_counts.json

@@ -1,9 +1,9 @@
 {
-  "cable:api_list_objects": 23,
+  "cable:api_list_objects": 17,
   "cable:list_objects_with_permission": 20,
   "cablebundle:api_list_objects": 12,
   "cablebundle:list_objects_with_permission": 16,
-  "cabletermination:api_list_objects": 16,
+  "cabletermination:api_list_objects": 14,
   "consoleconnection:list_objects_with_permission": 28,
   "consoleport:api_list_objects": 13,
   "consoleport:list_objects_with_permission": 17,

+ 412 - 29
netbox/dcim/tests/test_api.py

@@ -9,6 +9,7 @@ from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.prefetch import GenericPrefetch
 from django.db import connection
+from django.db.models import FETCH_PEERS, prefetch_related_objects
 from django.test import override_settings, tag
 from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
@@ -22,7 +23,7 @@ from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from dcim.api.serializers import InterfaceSerializer
 from dcim.api.serializers_.nested import NestedDeviceBaySerializer, NestedDeviceSerializer
-from dcim.api.views import DeviceViewSet, InterfaceViewSet
+from dcim.api.views import CableTerminationsPrefetch, DeviceViewSet, InterfaceViewSet
 from dcim.choices import *
 from dcim.constants import *
 from dcim.graphql.types import _CABLE_TERMINATION_MODELS
@@ -63,6 +64,24 @@ class AppTestCase(APITestCase):
         self.assertEqual(response.status_code, 200)
 
 
+def assert_list_query_count_is_constant(test, url, get_params, objects, check_results):
+    """
+    Assert that listing the first of the given objects runs as many queries as listing all of them. `get_params`
+    returns the query parameters selecting the given objects, and `check_results` validates the results listed.
+    """
+    def get_query_count(objs):
+        with CaptureQueriesContext(connection) as queries:
+            response = test.client.get(url, get_params(objs), **test.header)
+        test.assertHttpStatus(response, status.HTTP_200_OK)
+        check_results(objs, response.data['results'])
+        # Token authentication refreshes last_used once a minute
+        return sum('"users_token"' not in query['sql'] for query in queries.captured_queries)
+
+    # Warm per-process caches (e.g. ContentType)
+    get_query_count(objects)
+    test.assertEqual(get_query_count(objects[:1]), get_query_count(objects))
+
+
 class Mixins:
 
     class ComponentTraceMixin(APITestCase):
@@ -99,6 +118,37 @@ class Mixins:
             self.assertEqual(segment1[1]['label'], cable.label)
             self.assertEqual(segment1[2][0]['name'], peer_obj.name)
 
+    class LinkPeersMixin(APITestCase):
+        peer_termination_type = None
+
+        def test_list_objects_link_peers_query_count(self):
+            """
+            Listing cabled components loads their link peers, and the peers' devices and cables, with the same
+            number of queries however many components are listed.
+            """
+            if self.peer_termination_type is None:
+                raise NotImplementedError(_("Test case must set peer_termination_type"))
+            self.add_permissions(f'dcim.view_{self.model._meta.model_name}')
+            objects = list(self.model.objects.order_by('pk')[:3])
+            peer_device = create_test_device('Peer Device', site=Site.objects.first())
+            peer_names = {}
+            for index, obj in enumerate(objects):
+                peer = self.peer_termination_type.objects.create(device=peer_device, name=f'Peer {index}')
+                Cable(a_terminations=[obj], b_terminations=[peer]).save()
+                peer_names[obj.pk] = peer.name
+
+            def check_results(objs, results):
+                link_peers = {result['id']: [peer['name'] for peer in result['link_peers']] for result in results}
+                self.assertEqual(link_peers, {obj.pk: [peer_names[obj.pk]] for obj in objs})
+
+            assert_list_query_count_is_constant(
+                self,
+                self._get_list_url(),
+                lambda objs: {'id': [obj.pk for obj in objs], 'fields': 'id,link_peers'},
+                objects,
+                check_results,
+            )
+
     class FieldPrefetchMixin(APITestCase):
         viewset = None
         # Narrowed requests compared in addition to the default, brief and single-field ones
@@ -1425,6 +1475,18 @@ class RackTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    def test_ordering_by_count_without_field(self):
+        """Ordering by a count left out of the response still sorts by it."""
+        self.add_permissions('dcim.view_rack')
+        rack = Rack.objects.get(name='Rack 3')
+        create_test_device('Device 1', site=rack.site, rack=rack)
+        response = self.client.get(
+            f'{self._get_list_url()}?ordering=-device_count,name&fields=id,name', **self.header
+        )
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual([row['name'] for row in response.data['results']], ['Rack 3', 'Rack 1', 'Rack 2'])
+        self.assertNotIn('device_count', response.data['results'][0])
+
     def test_get_rack_elevation(self):
         """
         GET a single rack elevation.
@@ -3690,6 +3752,90 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         existing_iface.refresh_from_db()
         self.assertIsNotNone(existing_iface.module)
 
+    def test_adopt_components_changelog(self):
+        """
+        Adopted components should be change-logged with prechange data and have last_updated bumped.
+        """
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Adopt Changelog Test')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Adopt Changelog Module Type')
+        InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
+        ModuleBayTemplate.objects.create(module_type=module_type, name='Sub Bay')
+        module_bay = ModuleBay.objects.create(device=device, name='Adopt Changelog Bay')
+        # Adopted bays are also re-parented under the module's bay
+        expected_diffs = [
+            (Interface.objects.create(device=device, name='eth0', type='1000base-t'), {'module': None}),
+            (ModuleBay.objects.create(device=device, name='Sub Bay'), {'module': None, 'parent': None}),
+        ]
+        original_last_updated = {component.pk: component.last_updated for component, _ in expected_diffs}
+
+        url = reverse('dcim-api:module-list')
+        data = {
+            'device': device.pk,
+            'module_bay': module_bay.pk,
+            'module_type': module_type.pk,
+            'adopt_components': True,
+            'replicate_components': False,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module_pk = response.data['id']
+
+        for component, expected_pre in expected_diffs:
+            component.refresh_from_db()
+            self.assertEqual(component.module_id, module_pk)
+            self.assertGreater(component.last_updated, original_last_updated[component.pk])
+            objectchange = ObjectChange.objects.get(
+                changed_object_type=ObjectType.objects.get_for_model(component),
+                changed_object_id=component.pk,
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+            )
+            self.assertIsNotNone(objectchange.prechange_data)
+            self.assertIsNone(objectchange.prechange_data['module'])
+            self.assertEqual(objectchange.postchange_data['module'], module_pk)
+            self.assertEqual(objectchange.diff()['pre'], expected_pre)
+            # Trigger-maintained ltree columns must reflect the post-adoption location
+            if isinstance(component, ModuleBay):
+                self.assertEqual(objectchange.postchange_data['path'], str(component.path))
+                self.assertEqual(objectchange.postchange_data['sort_path'], component.sort_path)
+
+    def test_install_module_interface_parent_bridge_changelog(self):
+        """
+        Interface parent/bridge assignments made during module installation must record prechange data.
+        """
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Interface Parent Changelog Test')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Parent Changelog Module Type')
+        eth0 = InterfaceTemplate.objects.create(module_type=module_type, name='eth0', type='1000base-t')
+        InterfaceTemplate.objects.create(module_type=module_type, name='eth0.100', type='virtual', parent=eth0)
+        InterfaceTemplate.objects.create(module_type=module_type, name='br0', type='bridge')
+        InterfaceTemplate.objects.create(
+            module_type=module_type, name='eth1', type='1000base-t', bridge=InterfaceTemplate.objects.get(name='br0')
+        )
+        module_bay = ModuleBay.objects.create(device=device, name='Parent Changelog Bay')
+
+        url = reverse('dcim-api:module-list')
+        data = {
+            'device': device.pk,
+            'module_bay': module_bay.pk,
+            'module_type': module_type.pk,
+        }
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        for name, field in (('eth0.100', 'parent'), ('eth1', 'bridge')):
+            interface = Interface.objects.get(device=device, name=name)
+            objectchange = ObjectChange.objects.get(
+                changed_object_type=ObjectType.objects.get_for_model(Interface),
+                changed_object_id=interface.pk,
+                action=ObjectChangeActionChoices.ACTION_UPDATE,
+            )
+            self.assertIsNotNone(objectchange.prechange_data)
+            self.assertIsNone(objectchange.prechange_data[field])
+            self.assertEqual(objectchange.postchange_data[field], getattr(interface, f'{field}_id'))
+
     def test_replicate_components_conflict(self):
         """
         Installing a module with replicate_components=True when a component with the same name
@@ -3801,6 +3947,7 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         # eth1 should have been created
         self.assertTrue(device.interfaces.filter(name='eth1').exists())
 
+    @tag('regression')  # Issue #23478
     def test_module_token_no_position(self):
         """
         Installing a module whose type has a template with a MODULE_TOKEN placeholder into a
@@ -3809,46 +3956,158 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         self.add_permissions('dcim.add_module')
         manufacturer = Manufacturer.objects.get(name='Generic')
         device = create_test_device('Device for Token No-Position Test')
-        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token No-Position Module Type')
-        # Template name contains the MODULE_TOKEN placeholder
-        InterfaceTemplate.objects.create(
-            module_type=module_type, name=f'{MODULE_TOKEN}-eth0', type='1000base-t'
-        )
-        # Module bay has no position
-        module_bay = ModuleBay.objects.create(device=device, name='No-Position Bay')
+        url = reverse('dcim-api:module-list')
+        # One placeholder in each field that installation resolves, each on its own module type
+        templates = (
+            (InterfaceTemplate, {'name': f'{MODULE_TOKEN}-eth0', 'type': '1000base-t'}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': f'Port {MODULE_TOKEN}', 'type': '1000base-t'}),
+            (ModuleBayTemplate, {'name': f'Bay {MODULE_TOKEN}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': f'Bay {MODULE_TOKEN}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': MODULE_TOKEN}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            with self.subTest(template=template_model.__name__, attrs=attrs):
+                module_type = ModuleType.objects.create(
+                    manufacturer=manufacturer, model=f'Token No-Position Module Type {i}'
+                )
+                template_model.objects.create(module_type=module_type, **attrs)
+                module_bay = ModuleBay.objects.create(device=device, name=f'No-Position Bay {i}')
+
+                data = {
+                    'device': device.pk,
+                    'module_bay': module_bay.pk,
+                    'module_type': module_type.pk,
+                }
+                response = self.client.post(url, data, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+                self.assertIn('no position defined', str(response.data.get('__all__')))
+                self.assertFalse(Module.objects.filter(module_bay=module_bay).exists())
+
+    @tag('regression')  # Issue #23478
+    def test_module_token_depth_mismatch(self):
+        """
+        Installing a module whose template has more MODULE_TOKEN placeholders in a field than the
+        depth of the module bay tree should return a validation error.
+        """
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Token Depth Mismatch Test')
+        url = reverse('dcim-api:module-list')
+        # Two placeholders in each field that installation resolves, while every bay is one level deep
+        tokens = f'{MODULE_TOKEN}/{MODULE_TOKEN}'
+        templates = (
+            (InterfaceTemplate, {'name': f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', 'type': '1000base-t'}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': tokens, 'type': '1000base-t'}),
+            (ModuleBayTemplate, {'name': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': tokens}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            with self.subTest(template=template_model.__name__, attrs=attrs):
+                module_type = ModuleType.objects.create(
+                    manufacturer=manufacturer, model=f'Token Depth Mismatch Module Type {i}'
+                )
+                template_model.objects.create(module_type=module_type, **attrs)
+                module_bay = ModuleBay.objects.create(device=device, name=f'Depth 1 Bay {i}', position='1')
+
+                data = {
+                    'device': device.pk,
+                    'module_bay': module_bay.pk,
+                    'module_type': module_type.pk,
+                }
+                response = self.client.post(url, data, format='json', **self.header)
+                self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+                # Rejected by validation, not by the AbortRequest that component creation would raise
+                self.assertIn('2 placeholders given', str(response.data.get('__all__')))
+                self.assertFalse(Module.objects.filter(module_bay=module_bay).exists())
 
+    @tag('regression')  # Issue #23478
+    def test_module_token_empty_ancestor_position(self):
+        """One placeholder per level needs every bay position, a single placeholder only the immediate one."""
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Token Ancestor Test')
+        parent_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Parent Type')
+        parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay')
+        parent_module = Module.objects.create(device=device, module_bay=parent_bay, module_type=parent_type)
+        child_bay = ModuleBay.objects.create(device=device, module=parent_module, name='Child Bay', position='2')
         url = reverse('dcim-api:module-list')
+
+        level_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Level Type')
+        InterfaceTemplate.objects.create(
+            module_type=level_type, name='eth0', label=f'{MODULE_TOKEN}/{MODULE_TOKEN}', type='1000base-t'
+        )
         data = {
             'device': device.pk,
-            'module_bay': module_bay.pk,
-            'module_type': module_type.pk,
+            'module_bay': child_bay.pk,
+            'module_type': level_type.pk,
         }
         response = self.client.post(url, data, format='json', **self.header)
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertIn('no position defined', str(response.data.get('__all__')))
 
-    def test_module_token_depth_mismatch(self):
-        """
-        Installing a module whose template name has more MODULE_TOKEN placeholders than the
-        depth of the module bay tree should return a validation error.
-        """
+        leaf_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Ancestor Leaf Type')
+        InterfaceTemplate.objects.create(
+            module_type=leaf_type, name='eth0', label=f'Port {MODULE_TOKEN}', type='1000base-t'
+        )
+        data['module_type'] = leaf_type.pk
+        response = self.client.post(url, data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module = Module.objects.get(pk=response.data['id'])
+        self.assertEqual(module.interfaces.get().label, 'Port 2')
+
+    def test_module_token_resolves_in_label_and_module_bay(self):
+        """Placeholders in a label and in each module bay template field resolve against a nested bay."""
         self.add_permissions('dcim.add_module')
         manufacturer = Manufacturer.objects.get(name='Generic')
-        device = create_test_device('Device for Token Depth Mismatch Test')
-        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Depth Mismatch Module Type')
-        # Template name has two placeholders but the bay is at depth 1
+        device = create_test_device('Device for Token Resolution Test')
+        parent_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Resolution Parent Type')
+        parent_bay = ModuleBay.objects.create(device=device, name='Parent Bay', position='1')
+        parent_module = Module.objects.create(device=device, module_bay=parent_bay, module_type=parent_type)
+        child_bay = ModuleBay.objects.create(device=device, module=parent_module, name='Child Bay', position='2')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Token Resolution Module Type')
         InterfaceTemplate.objects.create(
-            module_type=module_type, name=f'{MODULE_TOKEN}-{MODULE_TOKEN}-eth0', type='1000base-t'
+            module_type=module_type, name='eth0', label=f'Port {MODULE_TOKEN}/{MODULE_TOKEN}', type='1000base-t'
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=module_type,
+            name=f'SFP {MODULE_TOKEN}',
+            label=f'{MODULE_TOKEN}/{MODULE_TOKEN}',
+            position=f'{MODULE_TOKEN}/1',
         )
-        module_bay = ModuleBay.objects.create(device=device, name='Depth 1 Bay', position='1')
 
         url = reverse('dcim-api:module-list')
         data = {
             'device': device.pk,
-            'module_bay': module_bay.pk,
+            'module_bay': child_bay.pk,
             'module_type': module_type.pk,
         }
         response = self.client.post(url, data, format='json', **self.header)
-        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        module = Module.objects.get(pk=response.data['id'])
+        self.assertEqual(module.interfaces.get().label, 'Port 1/2')
+        module_bay = module.modulebays.get()
+        self.assertEqual((module_bay.name, module_bay.label, module_bay.position), ('SFP 2', '1/2', '2/1'))
+
+    def test_module_bay_template_names_repeat_across_modules(self):
+        """Child module bays from one template may share a name across modules on a device."""
+        self.add_permissions('dcim.add_module')
+        manufacturer = Manufacturer.objects.get(name='Generic')
+        device = create_test_device('Device for Repeated Child Bay Test')
+        module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Repeated Child Bay Module Type')
+        ModuleBayTemplate.objects.create(module_type=module_type, name='SFP 1')
+
+        url = reverse('dcim-api:module-list')
+        for name in ('Repeated Bay 1', 'Repeated Bay 2'):
+            module_bay = ModuleBay.objects.create(device=device, name=name)
+            data = {
+                'device': device.pk,
+                'module_bay': module_bay.pk,
+                'module_type': module_type.pk,
+            }
+            response = self.client.post(url, data, format='json', **self.header)
+            self.assertHttpStatus(response, status.HTTP_201_CREATED)
+        self.assertEqual(device.modulebays.filter(name='SFP 1').count(), 2)
 
     def test_list_objects_by_profile_id(self):
         profiles = ModuleTypeProfile.objects.filter(name__startswith='Test').order_by('name')
@@ -3986,7 +4245,7 @@ class ModuleTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
-class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class ConsolePortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -4032,7 +4291,7 @@ class ConsolePortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTe
         ]
 
 
-class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = ConsoleServerPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -4078,7 +4337,7 @@ class ConsoleServerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.API
         ]
 
 
-class PowerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class PowerPortTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = PowerPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -4121,7 +4380,7 @@ class PowerPortTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         ]
 
 
-class PowerOutletTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
+class PowerOutletTestCase(Mixins.ComponentTraceMixin, Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = PowerOutlet
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -4382,6 +4641,45 @@ class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, A
             filters.append(f'device_id={device.pk}')
         return [filters[0], '&'.join(filters)]
 
+    def test_ordering_by_count_annotation(self):
+        """Ordering by a count annotation sorts the response whether or not it renders the count."""
+        self.add_permissions('dcim.view_interface', 'extras.view_exporttemplate')
+        interfaces = {name: Interface.objects.get(name=name) for name in ('Interface 2', 'Interface 3')}
+        IPAddress.objects.bulk_create((
+            IPAddress(address='192.0.2.1/24', assigned_object=interfaces['Interface 2']),
+            IPAddress(address='192.0.2.2/24', assigned_object=interfaces['Interface 2']),
+            IPAddress(address='192.0.2.3/24', assigned_object=interfaces['Interface 3']),
+        ))
+        export_template = ExportTemplate.objects.create(
+            name='Interfaces', template_code='{% for interface in queryset %}{{ interface.name }},{% endfor %}'
+        )
+        export_template.object_types.set([ObjectType.objects.get_for_model(Interface)])
+        url = f'{self._get_list_url()}?ordering=-count_ipaddresses'
+
+        # The count's source isn't a valid ordering field
+        response = self.client.get(f'{self._get_list_url()}?ordering=-_count_ipaddresses', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertEqual(
+            [row['name'] for row in response.data['results']], ['Interface 1', 'Interface 2', 'Interface 3']
+        )
+
+        # Responses without the count annotate it for the ordering alone
+        for params in ('', '&fields=id,name,count_ipaddresses', '&brief=1', '&fields=id,name', '&export=Interfaces'):
+            with self.subTest(params=params):
+                response = self.client.get(url + params, **self.header)
+                self.assertHttpStatus(response, status.HTTP_200_OK)
+                if 'export' in params:
+                    self.assertEqual(response.content.decode(), 'Interface 2,Interface 3,Interface 1,')
+                else:
+                    self.assertEqual(
+                        [row['name'] for row in response.data['results']],
+                        ['Interface 2', 'Interface 3', 'Interface 1'],
+                    )
+                    self.assertEqual(
+                        'count_ipaddresses' in response.data['results'][0],
+                        params in ('', '&fields=id,name,count_ipaddresses'),
+                    )
+
     def test_bulk_delete_child_interfaces(self):
         interface1 = Interface.objects.get(name='Interface 1')
         device = interface1.device
@@ -5091,7 +5389,7 @@ class InterfaceTestCase(Mixins.FieldPrefetchMixin, Mixins.ComponentTraceMixin, A
         self.assertNotIn('"ipam_fhrpgroupassignment"', count_sql)
 
 
-class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
+class FrontPortTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = FrontPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -5213,7 +5511,7 @@ class FrontPortTestCase(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_200_OK)
 
 
-class RearPortTestCase(APIViewTestCases.APIViewTestCase):
+class RearPortTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = RearPort
     brief_fields = ['_occupied', 'cable', 'description', 'device', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -5861,6 +6159,46 @@ class CableTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    def test_list_objects_terminations_query_count(self):
+        """
+        Listing cables loads their terminating objects, and those objects' devices and cables, with the same number
+        of queries however many cables are listed.
+        """
+        self.add_permissions('dcim.view_cable')
+
+        def check_results(cables, results):
+            self.assertEqual(len(results), len(cables))
+            for result in results:
+                for termination in (*result['a_terminations'], *result['b_terminations']):
+                    self.assertEqual(termination['object']['cable']['id'], result['id'])
+                    self.assertIn(termination['object']['device']['name'], ('Device 1', 'Device 2'))
+
+        assert_list_query_count_is_constant(
+            self,
+            self._get_list_url(),
+            lambda cables: {'id': [cable.pk for cable in cables]},
+            list(Cable.objects.order_by('pk')[:3]),
+            check_results,
+        )
+
+    def test_terminations_prefetch_leaves_shared_queryset_unchanged(self):
+        """
+        CableTerminationsPrefetch, which is shared across requests, gives Django a new queryset to add routing hints
+        to, and the prefetched terminations use the FETCH_PEERS fetch mode.
+        """
+        cable_prefetch = CableTerminationsPrefetch('terminations')
+        cables = list(Cable.objects.all())
+        prefetch_related_objects(cables, cable_prefetch)
+        self.assertEqual(cable_prefetch.queryset._hints, {})
+        self.assertIs(cables[0].terminations.all()[0]._state.fetch_mode, FETCH_PEERS)
+
+        # The two-level lookup used by the port viewsets
+        port_prefetch = CableTerminationsPrefetch()
+        interfaces = list(Interface.objects.filter(cable__isnull=False))
+        prefetch_related_objects(interfaces, port_prefetch)
+        self.assertEqual(port_prefetch.queryset._hints, {})
+        self.assertIs(interfaces[0].cable.terminations.all()[0]._state.fetch_mode, FETCH_PEERS)
+
     def test_repeated_put_does_not_accumulate_paths(self):
         """
         Repeating an identical PUT must leave the cable with the two paths its terminations trace.
@@ -6235,6 +6573,27 @@ class CableTerminationTestCase(
         for cable in cables:
             cable.save()
 
+    def test_list_objects_query_count(self):
+        """
+        Listing cable terminations loads their terminating objects, and those objects' devices and cables, with the
+        same number of queries however many terminations are listed.
+        """
+        self.add_permissions('dcim.view_cabletermination')
+
+        def check_results(cables, results):
+            self.assertEqual(len(results), 2 * len(cables))
+            for result in results:
+                self.assertEqual(result['termination']['cable']['id'], result['cable'])
+                self.assertIn(result['termination']['device']['name'], ('Device 1', 'Device 2'))
+
+        assert_list_query_count_is_constant(
+            self,
+            self._get_list_url(),
+            lambda cables: {'cable_id': [cable.pk for cable in cables]},
+            list(Cable.objects.order_by('pk')[:3]),
+            check_results,
+        )
+
 
 class ConnectedDeviceTestCase(APITestCase):
 
@@ -6374,6 +6733,29 @@ class VirtualChassisTestCase(APIViewTestCases.APIViewTestCase):
         member.refresh_from_db()
         self.assertEqual(member.virtual_chassis.name, 'Virtual Chassis 1')
 
+    @tag('regression')  # Ref: #23486
+    def test_create_with_master_records_prechange_data(self):
+        self.add_permissions('dcim.add_virtualchassis')
+        master = Device.objects.get(name='Device 10')
+
+        data = {'name': 'Virtual Chassis 4', 'master': master.pk}
+        response = self.client.post(self._get_list_url(), data, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_201_CREATED)
+
+        master.refresh_from_db()
+        self.assertEqual(master.virtual_chassis.pk, response.data['id'])
+        self.assertEqual(master.vc_position, 1)
+
+        change = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(Device),
+            changed_object_id=master.pk,
+        )
+        self.assertEqual(change.action, ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertIsNone(change.prechange_data['virtual_chassis'])
+        self.assertIsNone(change.prechange_data['vc_position'])
+        self.assertEqual(change.postchange_data['virtual_chassis'], response.data['id'])
+        self.assertEqual(change.postchange_data['vc_position'], 1)
+
 
 class PowerPanelTestCase(APIViewTestCases.APIViewTestCase):
     model = PowerPanel
@@ -6428,7 +6810,7 @@ class PowerPanelTestCase(APIViewTestCases.APIViewTestCase):
         }
 
 
-class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
+class PowerFeedTestCase(Mixins.LinkPeersMixin, APIViewTestCases.APIViewTestCase):
     model = PowerFeed
     brief_fields = ['_occupied', 'cable', 'description', 'display', 'id', 'name', 'url']
     bulk_update_data = {
@@ -6437,6 +6819,7 @@ class PowerFeedTestCase(APIViewTestCases.APIViewTestCase):
     bulk_update_invalid_data = {
         'status': 'not-a-valid-status',
     }
+    peer_termination_type = PowerPort
     user_permissions = ('dcim.view_powerpanel', )
 
     @classmethod

+ 51 - 0
netbox/dcim/tests/test_cablepaths.py

@@ -9,6 +9,7 @@ from dcim.svg import CableTraceSVG
 from dcim.tests.utils import BaseCablePathTestCase
 from dcim.utils import create_cablepaths, object_to_path_node
 from utilities.exceptions import AbortRequest
+from wireless.models import WirelessLink
 
 
 class LegacyCablePathTestCase(BaseCablePathTestCase):
@@ -3487,6 +3488,56 @@ class LegacyCablePathTestCase(BaseCablePathTestCase):
         self.assertIsNone(interface2.cable)
         self.assertPathIsNotSet(interface2)
 
+    def test_323_update_dependent_objects_extends_path_via_raw_circuit_termination(self):
+        """
+        [IF1] --C1-- [CT1] [CT2] --> [PN1], with CT2 written raw. Refs: #23595
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1')
+        providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
+        circuittermination1 = CircuitTermination.objects.create(
+            circuit=self.circuit,
+            termination=self.site,
+            term_side='A'
+        )
+        cable1 = Cable(a_terminations=[interface1], b_terminations=[circuittermination1])
+        cable1.save()
+        self.assertCurrentPathExists((interface1, cable1, circuittermination1), is_complete=False)
+
+        circuittermination2 = CircuitTermination(circuit=self.circuit, termination=providernetwork, term_side='Z')
+        circuittermination2.cache_related_objects()
+        circuittermination2.save_base(raw=True)
+        self.assertCurrentPathExists((interface1, cable1, circuittermination1), is_complete=False)
+
+        self.update_dependent_objects(circuittermination2)
+
+        self.assertCurrentPathExists(
+            (interface1, cable1, circuittermination1, circuittermination2, providernetwork),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 1)
+
+    def test_324_update_dependent_objects_traces_raw_wireless_link(self):
+        """
+        [IF1] --WL1-- [IF2], with WL1 written raw. Refs: #23595
+        """
+        interface1 = Interface.objects.create(device=self.device, name='Interface 1', type='ieee802.11ac')
+        interface2 = Interface.objects.create(device=self.device, name='Interface 2', type='ieee802.11ac')
+
+        wirelesslink1 = WirelessLink(interface_a=interface1, interface_b=interface2, ssid='Link 1')
+        wirelesslink1._interface_a_device = wirelesslink1._interface_b_device = self.device
+        wirelesslink1.save_base(raw=True)
+        self.assertEqual(CablePath.objects.count(), 0)
+
+        self.update_dependent_objects(wirelesslink1)
+
+        for interface in (interface1, interface2):
+            interface.refresh_from_db()
+            self.assertEqual(interface.wireless_link, wirelesslink1)
+        self.assertCurrentPathExists((interface1, wirelesslink1, interface2), is_complete=True, is_active=True)
+        self.assertCurrentPathExists((interface2, wirelesslink1, interface1), is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 2)
+
     def test_401_exclude_midspan_devices(self):
         """
         [IF1] --C1-- [FP1][Test Device][RP1] --C2-- [RP2][Test Device][FP2] --C3-- [IF2]

+ 130 - 3
netbox/dcim/tests/test_channelization.py

@@ -3,7 +3,7 @@ from unittest import mock
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ValidationError
-from django.db import connection, router
+from django.db import connection, router, transaction
 from django.test import Client, TestCase, TransactionTestCase
 from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
@@ -850,6 +850,133 @@ class ChannelizedCablePathTestCase(BaseCablePathTestCase):
                 (peer, cable, channel), pk=peer_paths[peer.pk], is_complete=True, is_active=True
             )
 
+    def test_124_update_dependent_objects_mirrors_cable_onto_raw_channel(self):
+        """
+        A channel subinterface written raw must inherit its parent's cable and trace in both directions once
+        Interface.update_dependent_objects() is called. Refs: #23595
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        channels[3].delete()
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far)
+        cable.clean()
+        cable.save()
+
+        channel = Interface(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4
+        )
+        channel.save_base(raw=True)
+        channel.refresh_from_db()
+        self.assertIsNone(channel.cable_id)
+        self.assertPathIsNotSet(channel)
+
+        self.update_dependent_objects(channel)
+
+        channel.refresh_from_db()
+        self.assertEqual(channel.cable_id, cable.pk)
+        self.assertEqual(channel.cable_positions, [4])
+        self.assertCurrentPathExists((channel, cable, far[3]), is_complete=True, is_active=True)
+        self.assertCurrentPathExists((far[3], cable, channel), is_complete=True, is_active=True)
+        self.assertEqual(CablePath.objects.count(), 8)
+
+    def _assert_hook_matches_save(self, channel, **attrs):
+        """
+        Assert that writing attrs to channel raw and calling update_dependent_objects() leaves the same cable
+        attributes and CablePaths as a normal save().
+        """
+        def snapshot():
+            interfaces = Interface.objects.order_by('pk').values_list(
+                'pk', 'cable_id', 'cable_end', 'cable_connector', 'cable_positions', '_path__path'
+            )
+            paths = sorted((p.path, p.is_complete, p.is_active, p.is_split) for p in CablePath.objects.all())
+            return list(interfaces), paths
+
+        class Rollback(Exception):
+            pass
+
+        try:
+            with transaction.atomic():
+                instance = Interface.objects.get(pk=channel.pk)
+                for name, value in attrs.items():
+                    setattr(instance, name, value)
+                instance.full_clean()
+                instance.save()
+                expected = snapshot()
+                raise Rollback
+        except Rollback:
+            pass
+
+        instance = Interface.objects.get(pk=channel.pk)
+        for name, value in attrs.items():
+            setattr(instance, name, value)
+        instance.save_base(raw=True)
+        self.update_dependent_objects(instance)
+
+        self.assertEqual(snapshot(), expected)
+
+    def _create_breakout(self, name, far_prefix):
+        parent, channels = self._create_channelized_interface(name, 4)
+        far = [
+            Interface.objects.create(
+                device=self.device, name=f'{far_prefix}{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+            )
+            for i in range(4)
+        ]
+        cable = Cable(profile=CableProfileChoices.BREAKOUT_1C4P_4C1P, a_terminations=[parent], b_terminations=far)
+        cable.clean()
+        cable.save()
+        return parent, channels, far, cable
+
+    def test_125_update_dependent_objects_clears_raw_detached_channel(self):
+        """
+        A channel subinterface detached by a raw write must drop its mirrored cable attributes and paths. Refs: #23595
+        """
+        _, channels, far, _ = self._create_breakout('et0', 'xe')
+
+        self._assert_hook_matches_save(
+            channels[3], parent=None, channel_id=None, type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+
+        channel = Interface.objects.get(pk=channels[3].pk)
+        self.assertIsNone(channel.cable_id)
+        self.assertPathIsNotSet(channel)
+        self.assertFalse(CablePath.objects.filter(_nodes__contains=channel).exists())
+
+    def test_126_update_dependent_objects_retraces_raw_reparented_channel(self):
+        """
+        A channel subinterface moved to another channelized parent by a raw write must leave no path from its former
+        parent's far end. Refs: #23595
+        """
+        parent1, channels1, far1, cable1 = self._create_breakout('et0', 'xe')
+        parent2, channels2, far2, cable2 = self._create_breakout('et1', 'ge')
+        channels2[3].delete()
+
+        self._assert_hook_matches_save(channels1[3], parent=Interface.objects.get(pk=parent2.pk), name='et1:4')
+
+        channel = Interface.objects.get(pk=channels1[3].pk)
+        self.assertEqual(channel.cable_id, cable2.pk)
+        self.assertCurrentPathExists((channel, cable2, far2[3]), is_complete=True, is_active=True)
+        self.assertCurrentPathExists((far2[3], cable2, channel), is_complete=True, is_active=True)
+        self.assertFalse(CablePath.objects.filter(_nodes__contains=cable1).filter(_nodes__contains=channel).exists())
+
+    def test_127_update_dependent_objects_clears_channel_of_uncabled_parent(self):
+        """
+        A channel subinterface moved by a raw write to a channelized parent with no cable must drop its mirrored
+        cable attributes and paths. Refs: #23595
+        """
+        _, channels, _, _ = self._create_breakout('et0', 'xe')
+        parent2, channels2 = self._create_channelized_interface('et1', 4)
+        channels2[3].delete()
+
+        self._assert_hook_matches_save(channels[3], parent=Interface.objects.get(pk=parent2.pk), name='et1:4')
+
+        channel = Interface.objects.get(pk=channels[3].pk)
+        self.assertIsNone(channel.cable_id)
+        self.assertFalse(CablePath.objects.filter(_nodes__contains=channel).exists())
+
 
 class ChannelizedInterfaceTestCase(TestCase):
     """
@@ -1633,7 +1760,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = Interface.objects.create(
             device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
-        self.add_permissions('dcim.add_interface', 'dcim.view_interface')
+        self.add_permissions('dcim.add_interface', 'dcim.view_interface', 'dcim.view_device')
 
         request_data = {
             'device': self.device.pk,
@@ -1656,7 +1783,7 @@ class ChannelizedBulkCreateTestCase(ViewTestCase):
         parent = InterfaceTemplate.objects.create(
             device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
         )
-        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate', 'dcim.view_devicetype')
 
         request_data = {
             'device_type': self.device_type.pk,

+ 92 - 0
netbox/dcim/tests/test_filtersets.py

@@ -61,6 +61,26 @@ class DeviceComponentFilterSetTestMixin:
         params = {'tenant': [tenants[0].slug, tenants[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_shared_names(self):
+        # A name or slug shared by several objects should resolve to a single filter value which still matches
+        # components on each of those objects
+        component = self.queryset.filter(_rack__isnull=False, _location__isnull=False).first()
+        device, rack, location = component.device, component._rack, component._location
+        params = (('device', device.name), ('rack', rack.name), ('location', location.slug))
+        counts = {param: self.filterset({param: [value]}, self.queryset).qs.count() for param, value in params}
+
+        # Move the component to a same-named device, rack, and location in another site
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        self.queryset.filter(pk=component.pk).update(
+            device=Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role),
+            _rack=Rack.objects.create(site=site, name=rack.name),
+            _location=Location.objects.create(site=site, name=location.name, slug=location.slug),
+        )
+        for param, value in params:
+            filterset = self.filterset({param: [value]}, self.queryset)
+            self.assertEqual(filterset.qs.count(), counts[param])
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
 
 class DeviceComponentTemplateFilterSetTestMixin:
 
@@ -572,6 +592,15 @@ class LocationTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'parent': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_parent_shared_slug(self):
+        # A location slug shared by several sites should resolve to a single filter value
+        location = Location.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        filterset = self.filterset({'parent': [location.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['parent']), 1)
+
     def test_ancestor(self):
         locations = Location.objects.filter(parent__isnull=True)[:2]
         params = {'ancestor_id': [locations[0].pk, locations[1].pk]}
@@ -2980,6 +3009,10 @@ class PlatformTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'available_for_device_type': device_type.pk}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+        # Reject invalid filter values
+        params = {'available_for_device_type': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
 
 class DeviceTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = Device.objects.all()
@@ -3886,6 +3919,18 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'rack': [racks[0].name, racks[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
 
+    def test_shared_names(self):
+        # A name or slug shared by several objects should resolve to a single filter value
+        location, rack, device = Location.objects.first(), Rack.objects.first(), Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        Rack.objects.create(site=site, name=rack.name)
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        for param, value in (('location', location.slug), ('rack', rack.name), ('device', device.name)):
+            filterset = self.filterset({param: [value]}, self.queryset)
+            self.assertTrue(filterset.is_valid())
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
 
 class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLoggedFilterSetTestMixin):
     queryset = ConsolePort.objects.all()
@@ -6212,6 +6257,20 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'vdc_identifier': vdc.values_list('identifier', flat=True)}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
 
+    def test_vdc_shared_name(self):
+        # A VDC name or identifier shared by several devices should resolve to a single filter value
+        vdc = VirtualDeviceContext.objects.first()
+        VirtualDeviceContext.objects.create(
+            device=Device.objects.exclude(pk=vdc.device_id).first(),
+            name=vdc.name,
+            identifier=vdc.identifier,
+            status=vdc.status,
+        )
+        for param, value in (('vdc', vdc.name), ('vdc_identifier', vdc.identifier)):
+            filterset = self.filterset({param: [value]}, self.queryset)
+            self.assertTrue(filterset.is_valid())
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
     def test_vlan(self):
         vlan = VLAN.objects.filter(qinq_role=VLANQinQRoleChoices.ROLE_SERVICE).first()
         params = {'vlan_id': vlan.pk}
@@ -7267,6 +7326,15 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTestMixin, ChangeLogge
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_installed_device_shared_name(self):
+        # A device name shared by several sites should resolve to a single filter value
+        device = Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        filterset = self.filterset({'installed_device': [device.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['installed_device']), 1)
+
 
 class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = InventoryItem.objects.all()
@@ -7663,6 +7731,15 @@ class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'master': [masters[0].name, masters[2].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_master_shared_name(self):
+        # A device name shared by several sites should resolve to a single filter value
+        device = Device.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Device.objects.create(site=site, name=device.name, device_type=device.device_type, role=device.role)
+        filterset = self.filterset({'master': [device.name]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['master']), 1)
+
     def test_name(self):
         params = {'name': ['VC 1', 'VC 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -9067,6 +9144,21 @@ class MACAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vminterface': [vm_interfaces[0].name, vm_interfaces[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_interface_shared_name(self):
+        # A name shared by several interfaces should resolve to a single filter value
+        interface = Interface.objects.create(
+            device=Device.objects.get(name='Device 2'), name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        vm_interface = VMInterface.objects.create(
+            virtual_machine=VirtualMachine.objects.get(name='Virtual Machine 2'), name='Interface 1'
+        )
+        MACAddress.objects.create(mac_address='00-00-00-01-01-99', assigned_object=interface)
+        MACAddress.objects.create(mac_address='00-00-00-04-01-99', assigned_object=vm_interface)
+        for param in ('interface', 'vminterface'):
+            filterset = self.filterset({param: ['Interface 1']}, self.queryset)
+            self.assertEqual(filterset.qs.count(), 2)
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
     def test_assigned(self):
         params = {'assigned': True}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)

+ 133 - 0
netbox/dcim/tests/test_forms.py

@@ -712,6 +712,139 @@ class ModuleFormTestCase(TestCase):
         intake.refresh_from_db()
         self.assertEqual(intake.module, module)
 
+    def test_module_form_rejects_module_token_without_bay_position(self):
+        """Replication and adoption reject a {module} token in any resolved field when the bay has no position."""
+        interface_type = InterfaceTypeChoices.TYPE_1GE_FIXED
+        templates = (
+            (InterfaceTemplate, {'name': 'eth{module}', 'type': interface_type}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': 'Port {module}', 'type': interface_type}),
+            (ModuleBayTemplate, {'name': 'Bay {module}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': 'Bay {module}'}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': '{module}'}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            module_type = ModuleType.objects.create(
+                manufacturer=self.module_type.manufacturer, model=f'Token No-Position Form Type {i}'
+            )
+            template_model.objects.create(module_type=module_type, **attrs)
+            for flags in ({'replicate_components': True}, {'adopt_components': True}):
+                with self.subTest(template=template_model.__name__, attrs=attrs, flags=flags):
+                    form = ModuleForm(data={
+                        'device': self.device.pk,
+                        'module_bay': self.bay_b.pk,
+                        'module_type': module_type.pk,
+                        'status': 'active',
+                        **flags,
+                    })
+                    self.assertFalse(form.is_valid())
+                    self.assertIn('no position defined', str(form.errors))
+
+    def test_module_form_rejects_module_token_depth_mismatch(self):
+        """Replication and adoption reject more {module} tokens in a resolved field than the bay depth."""
+        module_bay = ModuleBay.objects.create(device=self.device, name='Bay D', position='1')
+        interface_type = InterfaceTypeChoices.TYPE_1GE_FIXED
+        tokens = '{module}/{module}'
+        templates = (
+            (InterfaceTemplate, {'name': f'eth{tokens}', 'type': interface_type}),
+            (InterfaceTemplate, {'name': 'eth0', 'label': tokens, 'type': interface_type}),
+            (ModuleBayTemplate, {'name': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'label': tokens}),
+            (ModuleBayTemplate, {'name': 'Bay', 'position': tokens}),
+        )
+        for i, (template_model, attrs) in enumerate(templates, start=1):
+            module_type = ModuleType.objects.create(
+                manufacturer=self.module_type.manufacturer, model=f'Token Depth Form Type {i}'
+            )
+            template_model.objects.create(module_type=module_type, **attrs)
+            for flags in ({'replicate_components': True}, {'adopt_components': True}):
+                with self.subTest(template=template_model.__name__, attrs=attrs, flags=flags):
+                    form = ModuleForm(data={
+                        'device': self.device.pk,
+                        'module_bay': module_bay.pk,
+                        'module_type': module_type.pk,
+                        'status': 'active',
+                        **flags,
+                    })
+                    self.assertFalse(form.is_valid())
+                    self.assertIn('2 placeholders given', str(form.errors))
+
+    def test_module_form_adopt_only_creates_no_components(self):
+        """Adoption without replication adopts matching components and creates no others."""
+        adopt_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Adopt Only Form Type'
+        )
+        InterfaceTemplate.objects.create(module_type=adopt_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        InterfaceTemplate.objects.create(module_type=adopt_type, name='eth1', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        interface = Interface.objects.create(
+            device=self.device, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(data={
+            'device': self.device.pk,
+            'module_bay': self.bay_b.pk,
+            'module_type': adopt_type.pk,
+            'status': 'active',
+            'adopt_components': True,
+        })
+        self.assertTrue(form.is_valid(), form.errors)
+        module = form.save()
+        interface.refresh_from_db()
+        self.assertEqual(interface.module, module)
+        self.assertFalse(self.device.interfaces.filter(name='eth1').exists())
+
+    def test_module_form_adopt_only_rejects_component_of_another_module(self):
+        """Adoption without replication refuses a component that already belongs to another module."""
+        adopt_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Owned Adopt Form Type'
+        )
+        InterfaceTemplate.objects.create(module_type=adopt_type, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED)
+        Interface.objects.create(
+            device=self.device, module=self.module, name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(data={
+            'device': self.device.pk,
+            'module_bay': self.bay_b.pk,
+            'module_type': adopt_type.pk,
+            'status': 'active',
+            'adopt_components': True,
+        })
+        self.assertFalse(form.is_valid())
+        self.assertIn('already belongs to a module', str(form.errors))
+
+    def test_module_form_skips_template_checks_without_component_operations(self):
+        """Without replication or adoption, templates are neither checked nor instantiated."""
+        token_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='No Operations Form Type'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=token_type, name='eth0', label='Port {module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleForm(data={
+            'device': self.device.pk,
+            'module_bay': self.bay_b.pk,
+            'module_type': token_type.pk,
+            'status': 'active',
+        })
+        self.assertTrue(form.is_valid(), form.errors)
+        module = form.save()
+        self.assertFalse(module.interfaces.exists())
+
+    def test_module_import_form_rejects_module_token_without_bay_position(self):
+        """Bulk import applies the module form's placeholder checks."""
+        token_type = ModuleType.objects.create(
+            manufacturer=self.module_type.manufacturer, model='Token Import Type'
+        )
+        InterfaceTemplate.objects.create(
+            module_type=token_type, name='eth0', label='Port {module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        form = ModuleImportForm(data={
+            'device': self.device.name,
+            'module_bay': self.bay_b.name,
+            'module_type': token_type.model,
+            'status': 'active',
+        })
+        self.assertFalse(form.is_valid())
+        self.assertIn('no position defined', str(form.errors))
+
 
 class VCPositionTokenFormTestCase(TestCase):
 

+ 37 - 0
netbox/dcim/tests/test_models.py

@@ -17,6 +17,7 @@ from core.choices import ObjectChangeActionChoices
 from core.models import ObjectChange, ObjectType
 from dcim.choices import *
 from dcim.models import *
+from dcim.utils import resolve_module_placeholder
 from extras.events import serialize_for_event
 from extras.models import CustomField
 from ipam.models import IPAddress, Prefix
@@ -25,6 +26,7 @@ from netbox.context_managers import event_tracking
 from tenancy.models import Tenant
 from users.models import User
 from utilities.data import drange
+from utilities.exceptions import AbortRequest
 from utilities.testing import create_test_device, create_test_image
 from virtualization.models import Cluster, ClusterType
 
@@ -2181,6 +2183,41 @@ class ModuleBayTestCase(TestCase):
         interface = iface_module.interfaces.first()
         self.assertEqual(interface.name, 'Gi1/2')
 
+    @tag('regression')  # #23478
+    def test_module_token_depth_mismatch_in_label_raises_abort_request(self):
+        """An unresolvable {module} label aborts a direct module install with AbortRequest."""
+        device = Device.objects.first()
+        module_bay = ModuleBay.objects.create(device=device, name='Token Label Bay', position='1')
+        module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Token Label Type')
+        InterfaceTemplate.objects.create(
+            module_type=module_type, name='eth0', label='{module}/{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        module = Module(device=device, module_bay=module_bay, module_type=module_type)
+        with self.assertRaises(AbortRequest) as cm:
+            module.save()
+        self.assertIn('2 placeholders given', cm.exception.message)
+
+    @tag('regression')  # #23478
+    def test_module_token_without_bay_position_raises_abort_request(self):
+        """A {module} name in a bay without a position aborts a direct module install with AbortRequest."""
+        device = Device.objects.first()
+        module_bay = ModuleBay.objects.create(device=device, name='Token Name Bay')
+        module_type = ModuleType.objects.create(manufacturer=Manufacturer.objects.first(), model='Token Name Type')
+        InterfaceTemplate.objects.create(
+            module_type=module_type, name='eth{module}', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        module = Module(device=device, module_bay=module_bay, module_type=module_type)
+        with self.assertRaises(AbortRequest) as cm:
+            module.save()
+        self.assertIn('no position defined', cm.exception.message)
+
+    @tag('regression')  # #23478
+    def test_module_token_without_positions_raises_value_error(self):
+        """A {module} token with no bay position to resolve against raises ValueError."""
+        with self.assertRaises(ValueError) as cm:
+            resolve_module_placeholder('eth{module}', [])
+        self.assertIn('no position defined', str(cm.exception))
+
     @tag('regression')  # #20912
     def test_module_bay_parent_cleared_when_module_removed(self):
         """Test that the parent field is properly cleared when a module bay's module assignment is removed"""

+ 38 - 1
netbox/dcim/tests/test_module_moves.py

@@ -1059,6 +1059,15 @@ class ModuleMoveRenameTestCase(TestCase):
         with self.assertRaises(ValidationError):
             pos_module.full_clean()
 
+    def test_move_to_bay_without_position_rejects_templated_values(self):
+        """A move whose template-derived values would need a missing bay position is rejected."""
+        bare_slot = ModuleBay.objects.create(device=self.device, name='Bare Slot')
+
+        self.line_card.module_bay = bare_slot
+        with self.assertRaises(ValidationError) as cm:
+            self.line_card.full_clean()
+        self.assertIn('cannot be resolved for the destination', str(cm.exception))
+
 
 class ModuleCrossDeviceBlockerTestCase(TestCase):
 
@@ -1495,6 +1504,33 @@ class ModuleCrossDeviceMoveTestCase(TestCase):
         mapping.refresh_from_db()
         self.assertEqual(mapping.device, self.device_b)
 
+    @override_settings(BULK_UPDATE_CHUNK_SIZE=1)
+    def test_cross_device_move_changelogs_port_mappings(self):
+        front_port = FrontPort.objects.create(
+            device=self.device_a, module=self.line_card, name='Front 2', type=PortTypeChoices.TYPE_LC
+        )
+        rear_port = RearPort.objects.create(
+            device=self.device_a, module=self.line_card, name='Rear 2', type=PortTypeChoices.TYPE_LC, positions=1
+        )
+        PortMapping.objects.create(
+            front_port=front_port, front_port_position=1, rear_port=rear_port, rear_port_position=1
+        )
+        mappings = PortMapping.objects.filter(front_port__module=self.line_card)
+        self.assertEqual(mappings.count(), 2)
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = User.objects.create_user(username='mapping-mover')
+        with event_tracking(request):
+            self.line_card.snapshot()
+            self._move_to_device_b()
+        for mapping in mappings:
+            self.assertEqual(mapping.device, self.device_b)
+            change = ObjectChange.objects.get(
+                changed_object_type=ContentType.objects.get_for_model(PortMapping), changed_object_id=mapping.pk
+            )
+            self.assertEqual(change.prechange_data['device'], self.device_a.pk)
+            self.assertEqual(change.postchange_data['device'], self.device_b.pk)
+
     def test_cross_device_move_keeps_intra_module_power_link(self):
         outlet = self.line_card.poweroutlets.get(name='Outlet 1')
         self._move_to_device_b()
@@ -1541,7 +1577,8 @@ class ModuleCrossDeviceMoveTestCase(TestCase):
         ModuleBay.objects.bulk_create([
             ModuleBay(device=big_device, name=f'Storage Bay {i}') for i in range(1, 201)
         ])
-        target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay')
+        # Mirrors slot_2_b, the SFP name template needs a bay position
+        target_bay = ModuleBay.objects.create(device=big_device, name='Target Bay', position='2')
         big_count = move_and_count(big_device, target_bay)
         self.assertEqual(small_count, big_count)
 

+ 73 - 0
netbox/dcim/tests/test_port_mappings.py

@@ -15,6 +15,9 @@ from dcim.models import (
     FrontPort,
     FrontPortTemplate,
     Manufacturer,
+    Module,
+    ModuleBayTemplate,
+    ModuleType,
     PortMapping,
     PortTemplateMapping,
     RearPort,
@@ -258,6 +261,76 @@ class ReconcilePortTemplateMappingsTestCase(TestCase):
         self.assertEqual(PortTemplateMapping.objects.get(front_port=self.front_port).pk, original_pk)
 
 
+class InstantiatedPortMappingChangeLoggingTestCase(TestCase):
+    """
+    Port mappings replicated from device/module type templates must be change-logged.
+    """
+    @classmethod
+    def setUpTestData(cls):
+        cls.user = User.objects.create_user(username='testuser', password='pw')
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        cls.role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1', color='ff0000')
+        cls.site = Site.objects.create(name='Site 1', slug='site-1')
+
+        cls.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'
+        )
+        ModuleBayTemplate.objects.create(device_type=cls.device_type, name='Slot 1', position='1')
+        cls.module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1')
+        for type_kwargs in ({'device_type': cls.device_type}, {'module_type': cls.module_type}):
+            front_port = FrontPortTemplate.objects.create(
+                name='Front {module}' if 'module_type' in type_kwargs else 'Front',
+                type=PortTypeChoices.TYPE_8P8C, positions=2, **type_kwargs
+            )
+            rear_port = RearPortTemplate.objects.create(
+                name='Rear {module}' if 'module_type' in type_kwargs else 'Rear',
+                type=PortTypeChoices.TYPE_8P8C, positions=2, **type_kwargs
+            )
+            for position in (1, 2):
+                PortTemplateMapping.objects.create(
+                    front_port=front_port, front_port_position=position,
+                    rear_port=rear_port, rear_port_position=position,
+                    **type_kwargs
+                )
+
+    def _mapping_changes(self, request):
+        return ObjectChange.objects.filter(
+            request_id=request.id,
+            changed_object_type=ContentType.objects.get_for_model(PortMapping),
+            action=ObjectChangeActionChoices.ACTION_CREATE,
+        )
+
+    @tag('regression')  # Ref: #23597
+    def test_device_creation_records_mappings(self):
+        request = _build_request(self.user)
+        with event_tracking(request):
+            device = Device.objects.create(
+                device_type=self.device_type, role=self.role, name='Device 1', site=self.site
+            )
+
+        mapping_pks = set(device.port_mappings.values_list('pk', flat=True))
+        self.assertEqual(len(mapping_pks), 2)
+        self.assertEqual(
+            set(self._mapping_changes(request).values_list('changed_object_id', flat=True)), mapping_pks
+        )
+
+    @tag('regression')  # Ref: #23597
+    def test_module_installation_records_mappings(self):
+        device = Device.objects.create(device_type=self.device_type, role=self.role, name='Device 1', site=self.site)
+        request = _build_request(self.user)
+        with event_tracking(request):
+            module = Module.objects.create(
+                device=device, module_bay=device.modulebays.get(name='Slot 1'), module_type=self.module_type
+            )
+
+        mapping_pks = set(PortMapping.objects.filter(front_port__module=module).values_list('pk', flat=True))
+        self.assertEqual(len(mapping_pks), 2)
+        self.assertEqual(
+            set(self._mapping_changes(request).values_list('changed_object_id', flat=True)), mapping_pks
+        )
+
+
 class PortMappingAPITestCase(APITestCase):
     """
     Exercise the reconcile behaviour through PortSerializer.create()/update() over the REST API,

+ 257 - 4
netbox/dcim/tests/test_views.py

@@ -2440,6 +2440,7 @@ class ModuleBayTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsolePortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2473,6 +2474,7 @@ class ConsolePortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ConsoleServerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2505,6 +2507,7 @@ class ConsoleServerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateVie
 
 class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2543,6 +2546,7 @@ class PowerPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = PowerOutletTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_powerporttemplate')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2585,6 +2589,7 @@ class PowerOutletTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestC
 
 class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InterfaceTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2625,7 +2630,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         module_type = ModuleType.objects.create(manufacturer=device_type.manufacturer, model='Module Type 1')
         return_url = reverse('dcim:moduletype_interfaces', kwargs={'pk': module_type.pk})
 
-        self.add_permissions('dcim.add_interfacetemplate')
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_moduletype')
 
         response = self.client.post(
             f"{self._get_url('add')}?device_type={device_type.pk}&return_url={return_url}",
@@ -2647,6 +2652,7 @@ class InterfaceTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = FrontPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_rearporttemplate')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
     @classmethod
@@ -2698,6 +2704,7 @@ class FrontPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = RearPortTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2732,6 +2739,7 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
 
 class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = ModuleBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2813,6 +2821,7 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = DeviceBayTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -2848,6 +2857,7 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
 
 class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = InventoryItemTemplate
+    bulk_create_permissions = ('dcim.view_devicetype', 'dcim.view_manufacturer')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3797,6 +3807,7 @@ class ModuleTestCase(
 
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3913,6 +3924,7 @@ class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsoleServerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -3983,6 +3995,7 @@ class ConsoleServerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4059,6 +4072,7 @@ class PowerPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = PowerOutlet
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_powerport', 'extras.view_tag', 'users.view_owner')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4145,6 +4159,10 @@ class PowerOutletTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = Interface
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_interface', 'extras.view_tag', 'ipam.view_vlan', 'ipam.view_vrf',
+        'wireless.view_wirelesslan',
+    )
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4363,6 +4381,49 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
             [visible_device.pk]
         )
 
+    @tag('regression')  # Issue #23585
+    def test_add_interface_excludes_restricted_devices(self):
+        """
+        The interface form only displays and accepts devices the user can view.
+        """
+        visible_device = Device.objects.get(name='Device 1')
+        site = Site.objects.create(name='Site 2', slug='site-2')
+        hidden_device = create_test_device('Hidden Device', site=site)
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'site__slug': 'site-1'},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(Device))
+        self.add_permissions('dcim.add_interface')
+        url = reverse('dcim:interface_add')
+        data = {
+            'device': hidden_device.pk,
+            'name': 'Restricted Interface',
+            'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
+        }
+
+        # Creation rejects the hidden device
+        response = self.client.post(url, data=post_data(data))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('device', response.context['form'].errors)
+        self.assertNotContains(response, hidden_device.name)
+        self.assertFalse(Interface.objects.filter(name='Restricted Interface').exists())
+
+        # The form does not display the hidden device
+        response = self.client.get(url, {'device': hidden_device.pk})
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, hidden_device.name)
+
+        # A visible device is accepted
+        data['device'] = visible_device.pk
+        response = self.client.post(url, data=post_data(data))
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(Interface.objects.filter(device=visible_device, name='Restricted Interface').exists())
+
     def test_rename_select_all_spans_pages(self):
         """
         Tests the bulk rename functionality for interfaces spanning multiple pages in the UI.
@@ -4407,7 +4468,10 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
         Submitting the Interface form with a mac_address string creates a MACAddress
         and sets it as primary in one request.
         """
-        self.add_permissions('dcim.add_interface', 'dcim.add_macaddress')
+        self.add_permissions(
+            'dcim.add_interface', 'dcim.add_macaddress', 'dcim.view_device', 'dcim.view_interface', 'extras.view_tag',
+            'ipam.view_vlan', 'ipam.view_vrf', 'wireless.view_wirelesslan',
+        )
 
         data = {**self.form_data, 'mac_address': 'AA:BB:CC:DD:EE:FF', 'changelog_message': 'test'}
         response = self.client.post(self._get_url('add'), data=post_data(data))
@@ -4557,6 +4621,7 @@ class InterfaceTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = FrontPort
+    bulk_create_permissions = ('dcim.view_device', 'dcim.view_rearport', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label', 'rear_port')
 
     @classmethod
@@ -4679,7 +4744,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
             RearPort(device=device, name='Rear Port 8', positions=2),
         )
         RearPort.objects.bulk_create(rear_ports)
-        self.add_permissions('dcim.add_frontport')
+        self.add_permissions('dcim.add_frontport', 'dcim.view_device', 'dcim.view_rearport')
 
         response = self.client.post(self._get_url('add'), post_data({
             'device': device.pk,
@@ -4706,6 +4771,50 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
                 [(1, rear_port.pk, 1), (2, rear_port.pk, 2)]
             )
 
+    @tag('regression')  # Issue #23585
+    def test_add_front_port_excludes_restricted_rear_ports(self):
+        """
+        The front port form only offers and accepts rear ports the user can view.
+        """
+        device = Device.objects.get(name='Device 1')
+        visible_rear_port = RearPort.objects.create(device=device, name='Visible Rear Port')
+        hidden_rear_port = RearPort.objects.create(device=device, name='Hidden Rear Port')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'pk': visible_rear_port.pk},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(RearPort))
+        self.add_permissions('dcim.add_frontport', 'dcim.view_device')
+        url = reverse('dcim:frontport_add')
+        data = {
+            'device': device.pk,
+            'name': 'Restricted Front Port',
+            'type': PortTypeChoices.TYPE_8P8C,
+            'positions': 1,
+        }
+
+        # Creation rejects the hidden rear port
+        response = self.client.post(url, data=post_data({**data, 'rear_ports': [f'{hidden_rear_port.pk}:1']}))
+        self.assertHttpStatus(response, 200)
+        self.assertIn('rear_ports', response.context['form'].errors)
+        self.assertFalse(FrontPort.objects.filter(name='Restricted Front Port').exists())
+
+        # The form offers only the visible rear port
+        response = self.client.get(url, {'device': device.pk})
+        self.assertContains(response, visible_rear_port.name)
+        self.assertNotContains(response, hidden_rear_port.name)
+
+        # A visible rear port is accepted
+        response = self.client.post(url, data=post_data({**data, 'rear_ports': [f'{visible_rear_port.pk}:1']}))
+        self.assertHttpStatus(response, 302)
+        self.assertTrue(
+            PortMapping.objects.filter(front_port__name='Restricted Front Port', rear_port=visible_rear_port).exists()
+        )
+
     def test_trace(self):
         self.add_permissions(
             'dcim.view_frontport',
@@ -4727,6 +4836,7 @@ class FrontPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = RearPort
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4800,6 +4910,7 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ModuleBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -4953,6 +5064,7 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -5006,6 +5118,9 @@ class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = InventoryItem
+    bulk_create_permissions = (
+        'dcim.view_device', 'dcim.view_inventoryitemrole', 'dcim.view_manufacturer', 'extras.view_tag',
+    )
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -5827,6 +5942,140 @@ class VirtualChassisTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertIsNone(member.virtual_chassis)
         self.assertIsNone(member.vc_position)
 
+    def _get_member_changes(self, device):
+        return ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(Device),
+            changed_object_id=device.pk,
+        ).order_by('pk')
+
+    @tag('regression')  # Ref: #23486
+    def test_edit_member_records_single_change_with_prechange_data(self):
+        self.add_permissions('dcim.change_virtualchassis')
+        vc = VirtualChassis.objects.get(name='VC1')
+        member1, member2, member3 = vc.members.order_by('vc_position')
+
+        data = {
+            'name': vc.name,
+            'domain': vc.domain,
+            'master': vc.master.pk,
+            'form-TOTAL_FORMS': 3,
+            'form-INITIAL_FORMS': 3,
+            'form-MIN_NUM_FORMS': 0,
+            'form-MAX_NUM_FORMS': 1000,
+            'form-0-id': member1.pk,
+            'form-0-vc_position': 1,
+            'form-0-vc_priority': 20,
+            'form-1-id': member2.pk,
+            'form-1-vc_position': 2,
+            'form-1-vc_priority': '',
+            'form-2-id': member3.pk,
+            'form-2-vc_position': 3,
+            'form-2-vc_priority': '',
+        }
+        response = self.client.post(self._get_url('edit', vc), data)
+        self.assertHttpStatus(response, 302)
+
+        member1.refresh_from_db()
+        self.assertEqual(member1.vc_position, 1)
+        self.assertEqual(member1.vc_priority, 20)
+
+        # Exactly one change is recorded, spanning the member's original and final state
+        changes = self._get_member_changes(member1)
+        self.assertEqual(changes.count(), 1)
+        change = changes.first()
+        self.assertEqual(change.action, ObjectChangeActionChoices.ACTION_UPDATE)
+        self.assertEqual(change.prechange_data['vc_position'], 1)
+        self.assertIsNone(change.prechange_data['vc_priority'])
+        self.assertEqual(change.postchange_data['vc_position'], 1)
+        self.assertEqual(change.postchange_data['vc_priority'], 20)
+
+        # Unchanged members produce no changelog entries
+        self.assertFalse(self._get_member_changes(member2).exists())
+        self.assertFalse(self._get_member_changes(member3).exists())
+
+    @tag('regression')  # Ref: #23486
+    def test_edit_member_swap_positions(self):
+        self.add_permissions('dcim.change_virtualchassis')
+        vc = VirtualChassis.objects.get(name='VC1')
+        member1, member2, member3 = vc.members.order_by('vc_position')
+
+        data = {
+            'name': vc.name,
+            'domain': vc.domain,
+            'master': vc.master.pk,
+            'form-TOTAL_FORMS': 3,
+            'form-INITIAL_FORMS': 3,
+            'form-MIN_NUM_FORMS': 0,
+            'form-MAX_NUM_FORMS': 1000,
+            'form-0-id': member1.pk,
+            'form-0-vc_position': 2,
+            'form-0-vc_priority': '',
+            'form-1-id': member2.pk,
+            'form-1-vc_position': 1,
+            'form-1-vc_priority': '',
+            'form-2-id': member3.pk,
+            'form-2-vc_position': 3,
+            'form-2-vc_priority': '',
+        }
+        response = self.client.post(self._get_url('edit', vc), data)
+        self.assertHttpStatus(response, 302)
+
+        member1.refresh_from_db()
+        member2.refresh_from_db()
+        self.assertEqual(member1.vc_position, 2)
+        self.assertEqual(member2.vc_position, 1)
+
+        for member, old_position, new_position in ((member1, 1, 2), (member2, 2, 1)):
+            changes = self._get_member_changes(member)
+            self.assertEqual(changes.count(), 1)
+            change = changes.first()
+            self.assertEqual(change.prechange_data['vc_position'], old_position)
+            self.assertEqual(change.postchange_data['vc_position'], new_position)
+            self.assertEqual(change.postchange_data['virtual_chassis'], vc.pk)
+
+    @tag('regression')  # Ref: #23486
+    def test_delete_records_valid_member_changes(self):
+        self.add_permissions('dcim.delete_virtualchassis')
+        vc = VirtualChassis.objects.get(name='VC1')
+        members = list(vc.members.order_by('vc_position'))
+        Device.objects.filter(pk=members[0].pk).update(vc_priority=10)
+
+        response = self.client.post(self._get_url('delete', vc), {'confirm': True})
+        self.assertHttpStatus(response, 302)
+        self.assertFalse(VirtualChassis.objects.filter(pk=vc.pk).exists())
+
+        for member in members:
+            member.refresh_from_db()
+            self.assertIsNone(member.virtual_chassis)
+            self.assertIsNone(member.vc_position)
+            self.assertIsNone(member.vc_priority)
+
+            # Each member is detached and then cleared in two changes, each with prechange data and valid on
+            # its own (a virtual chassis is never recorded without a position)
+            changes = list(self._get_member_changes(member))
+            self.assertEqual(len(changes), 2)
+            detach, clear = changes
+            self.assertEqual(detach.action, ObjectChangeActionChoices.ACTION_UPDATE)
+            self.assertEqual(detach.prechange_data['virtual_chassis'], vc.pk)
+            self.assertIsNotNone(detach.prechange_data['vc_position'])
+            self.assertIsNone(detach.postchange_data['virtual_chassis'])
+            self.assertEqual(detach.postchange_data['vc_position'], detach.prechange_data['vc_position'])
+            self.assertEqual(clear.action, ObjectChangeActionChoices.ACTION_UPDATE)
+            self.assertIsNone(clear.prechange_data['virtual_chassis'])
+            self.assertEqual(clear.prechange_data['vc_position'], detach.postchange_data['vc_position'])
+            self.assertIsNone(clear.postchange_data['vc_position'])
+            self.assertIsNone(clear.postchange_data['vc_priority'])
+        self.assertEqual(self._get_member_changes(members[0]).first().prechange_data['vc_priority'], 10)
+
+        # The VC deletion is recorded once, before its (former) master is detached
+        vc_change = ObjectChange.objects.get(
+            changed_object_type=ContentType.objects.get_for_model(VirtualChassis),
+            changed_object_id=vc.pk,
+        )
+        self.assertEqual(vc_change.action, ObjectChangeActionChoices.ACTION_DELETE)
+        self.assertEqual(vc_change.prechange_data['master'], members[0].pk)
+        self.assertLess(vc_change.pk, self._get_member_changes(members[0]).first().pk)
+
 
 class PowerPanelTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = PowerPanel
@@ -5985,6 +6234,7 @@ class PowerFeedTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
 class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingIntakeTemplate
+    bulk_create_permissions = ('dcim.view_devicetype',)
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6029,6 +6279,7 @@ class CoolingIntakeTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTes
 
 class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = CoolingOutflowTemplate
+    bulk_create_permissions = ('dcim.view_coolingintaketemplate', 'dcim.view_devicetype')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6074,6 +6325,7 @@ class CoolingOutflowTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTe
 
 class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingIntake
+    bulk_create_permissions = ('dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6139,6 +6391,7 @@ class CoolingIntakeTestCase(ViewTestCases.DeviceComponentViewTestCase):
 
 class CoolingOutflowTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = CoolingOutflow
+    bulk_create_permissions = ('dcim.view_coolingintake', 'dcim.view_device', 'extras.view_tag')
     validation_excluded_fields = ('name', 'label')
 
     @classmethod
@@ -6593,7 +6846,7 @@ class MACAddressTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     def test_set_primary_anonymous_redirects_to_login(self):
         """
         With LOGIN_REQUIRED, an unauthenticated request is redirected to the login page (via
-        ConditionalLoginRequiredMixin) rather than 404ing or acting, for both GET and POST.
+        LoginRequiredMiddleware) rather than 404ing or acting, for both GET and POST.
         """
         self.client.logout()
         mac = MACAddress.objects.first()

+ 25 - 1
netbox/dcim/tests/utils.py

@@ -1,8 +1,13 @@
-from django.test import TestCase
+import uuid
+
+from django.test import RequestFactory, TestCase
 
 from circuits.models import *
+from core.models import ObjectChange
 from dcim.models import *
 from dcim.utils import object_to_path_node
+from netbox.context_managers import event_tracking
+from utilities.testing.utils import create_test_user
 
 __all__ = (
     'BaseCablePathTestCase',
@@ -101,3 +106,22 @@ class BaseCablePathTestCase(TestCase):
         if msg is None:
             msg = f"Path #{origin._path_id} set as origin on {origin}; should be None!"
         self.assertIsNone(origin._path_id, msg=msg)
+
+    def update_dependent_objects(self, obj):
+        """
+        Call update_dependent_objects() on a fresh copy of obj twice under change tracking, asserting that neither
+        call records an ObjectChange and that the second call leaves the CablePaths unchanged.
+        """
+        request = RequestFactory().get('/')
+        request.id = uuid.uuid4()
+        request.user = create_test_user()
+
+        def get_paths():
+            return sorted((p.path, p.is_complete, p.is_active, p.is_split) for p in CablePath.objects.all())
+
+        with event_tracking(request):
+            type(obj).objects.get(pk=obj.pk).update_dependent_objects()
+            paths = get_paths()
+            type(obj).objects.get(pk=obj.pk).update_dependent_objects()
+        self.assertEqual(get_paths(), paths)
+        self.assertFalse(ObjectChange.objects.filter(request_id=request.id).exists())

+ 33 - 13
netbox/dcim/utils.py

@@ -5,6 +5,7 @@ from django.apps import apps
 from django.contrib.contenttypes.models import ContentType
 from django.db import router, transaction
 from django.db.models import Q
+from django.db.models.signals import post_save
 from django.utils.translation import gettext as _
 
 from dcim.constants import MODULE_TOKEN
@@ -81,24 +82,31 @@ def resolve_module_placeholder(value, positions):
     Multiple tokens must match the tree depth and resolve level-by-level.
 
     Returns the resolved string.
-    Raises ValueError if token count is greater than 1 and doesn't match tree depth.
+    Raises ValueError if token count is greater than 1 and doesn't match tree depth,
+    or if a position a token resolves to is missing or empty.
     """
     if MODULE_TOKEN not in value:
         return value
 
     token_count = value.count(MODULE_TOKEN)
     if token_count == 1:
-        return value.replace(MODULE_TOKEN, positions[-1])
-    if token_count == len(positions):
-        for pos in positions:
-            value = value.replace(MODULE_TOKEN, pos, 1)
-        return value
-    raise ValueError(
-        _("Cannot install module with placeholder values in a module bay tree "
-          "{level} levels deep but {tokens} placeholders given.").format(
-            level=len(positions), tokens=token_count
+        referenced = positions[-1:]
+    elif token_count == len(positions):
+        referenced = positions
+    else:
+        raise ValueError(
+            _("Cannot install module with placeholder values in a module bay tree "
+              "{level} levels deep but {tokens} placeholders given.").format(
+                level=len(positions), tokens=token_count
+            )
         )
-    )
+    if not referenced or not all(referenced):
+        raise ValueError(
+            _("Cannot install module with placeholder values in a module bay with no position defined.")
+        )
+    for pos in referenced:
+        value = value.replace(MODULE_TOKEN, pos, 1)
+    return value
 
 
 def compile_path_node(ct_id, object_id):
@@ -475,6 +483,7 @@ def update_interface_parents(device, interface_templates, module=None):
 
     for interface_template in interface_templates.exclude(parent=None):
         interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+        interface.snapshot()
         interface.parent = Interface.objects.get(
             device=device,
             name=interface_template.parent.resolve_name(module=module)
@@ -497,6 +506,7 @@ def update_interface_bridges(device, interface_templates, module=None):
         )
 
         if interface_template.bridge:
+            interface.snapshot()
             interface.bridge = Interface.objects.get(
                 device=device,
                 name=interface_template.bridge.resolve_name(module=module, device=device)
@@ -535,8 +545,18 @@ def create_port_mappings(device, device_or_module_type, module=None):
                 rear_port_position=template.rear_port_position,
             )
         )
-    # Bulk-created (no per-mapping ObjectChange) to match how every other component is instantiated.
-    PortMapping.objects.bulk_create(mappings)
+    # Send post_save for each mapping to record its creation
+    mappings = PortMapping.objects.bulk_create(mappings)
+    using = router.db_for_write(PortMapping)
+    for mapping in mappings:
+        post_save.send(
+            sender=PortMapping,
+            instance=mapping,
+            created=True,
+            raw=False,
+            using=using,
+            update_fields=None
+        )
 
 
 def reconcile_port_mappings(mapping_model, parent_field, parent, desired):

+ 9 - 9
netbox/dcim/views.py

@@ -41,7 +41,6 @@ from utilities.query import count_related
 from utilities.query_functions import CollateAsChar
 from utilities.request import safe_for_redirect
 from utilities.views import (
-    ConditionalLoginRequiredMixin,
     GetRelatedModelsMixin,
     GetReturnURLMixin,
     ObjectPermissionRequiredMixin,
@@ -5224,6 +5223,11 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
         vc_form.fields['master'].queryset = members_queryset
         formset = VCMemberFormSet(request.POST, queryset=members_queryset)
 
+        # Snapshot each member before validation applies the submitted data to the instance
+        for form in formset.initial_forms:
+            if form.instance.pk:
+                form.instance.snapshot()
+
         if vc_form.is_valid() and formset.is_valid():
             virtual_chassis._changelog_message = vc_form.cleaned_data.pop('changelog_message', '')
 
@@ -5232,14 +5236,10 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
                 # Save the VirtualChassis
                 vc_form.save()
 
-                # Nullify the vc_position of each member first to allow reordering without raising an IntegrityError on
-                # duplicate positions. Then save each member instance.
+                # Nullify vc_position of each changed member first to allow reordering without an IntegrityError on
+                # duplicate positions. A queryset update keeps this transient state out of the changelog.
                 members = formset.save(commit=False)
-                devices = Device.objects.filter(pk__in=[m.pk for m in members])
-                for device in devices:
-                    device.snapshot()
-                    device.vc_position = None
-                    device.save()
+                Device.objects.filter(pk__in=[m.pk for m in members]).update(vc_position=None)
                 for member in members:
                     member.save()
 
@@ -5887,7 +5887,7 @@ class MACAddressDeleteView(generic.ObjectDeleteView):
 
 
 @register_model_view(MACAddress, 'set_primary')
-class MACAddressSetPrimaryView(ConditionalLoginRequiredMixin, GetReturnURLMixin, View):
+class MACAddressSetPrimaryView(GetReturnURLMixin, View):
     queryset = MACAddress.objects.all()
 
     def get(self, request, pk):

+ 1 - 1
netbox/extras/apps.py

@@ -8,7 +8,7 @@ class ExtrasConfig(AppConfig):
         from netbox.event_rules import register_event_rule_action
         from netbox.models.features import register_models
 
-        from . import dashboard, lookups, search, signals  # noqa: F401
+        from . import dashboard, jobs, lookups, search, signals  # noqa: F401
         from .event_rules import NotificationAction, ScriptAction, WebhookAction
 
         # Register models

+ 2 - 1
netbox/extras/filtersets.py

@@ -15,6 +15,7 @@ from utilities.filters import (
     MultiValueCharFilter,
     MultiValueContentTypeFilter,
     MultiValueNumberFilter,
+    NonUniqueModelMultipleChoiceFilter,
 )
 from utilities.filtersets import register_filterset
 from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -760,7 +761,7 @@ class ConfigContextFilterSet(OwnerFilterMixin, ChangeLoggedModelFilterSet):
         queryset=Location.objects.all(),
         label=_('Location'),
     )
-    location = django_filters.ModelMultipleChoiceFilter(
+    location = NonUniqueModelMultipleChoiceFilter(
         field_name='locations__slug',
         queryset=Location.objects.all(),
         to_field_name='slug',

+ 7 - 0
netbox/extras/jobs.py

@@ -4,10 +4,12 @@ from contextlib import ExitStack
 
 from django.apps import apps
 from django.db import DEFAULT_DB_ALIAS, router, transaction
+from django.urls import reverse
 from django.utils.translation import gettext as _
 from django_pg_utils import advisory_lock
 
 from core.signals import clear_events
+from core.utils import register_job_result_url
 from dcim.models import Device
 from extras.choices import CustomFieldStatusChoices
 from extras.constants import CUSTOMFIELD_JOB_TIMEOUT
@@ -260,6 +262,11 @@ class CustomFieldPurgeJob(CustomFieldDataJob):
 #
 
 
+@register_job_result_url(ScriptModel, raise_exceptions=True)
+def script_job_result_url(job):
+    return reverse('extras:script_result', kwargs={'job_pk': job.pk})
+
+
 class ScriptJob(JobRunner):
     """
     Script execution job.

+ 2 - 1
netbox/extras/models/mixins.py

@@ -40,7 +40,7 @@ class CustomStoragesLoader(importlib.abc.Loader):
     def exec_module(self, module):
         with storages["scripts"].open(self.filename, 'rb') as f:
             code = f.read()
-        exec(code, module.__dict__)
+        exec(compile(code, module.__file__, 'exec'), module.__dict__)
 
 
 class PythonModuleMixin:
@@ -80,6 +80,7 @@ class PythonModuleMixin:
         spec = importlib.util.spec_from_file_location(module_name, self.name)
         if spec is None:
             raise ModuleNotFoundError(f"Could not find module: {self.python_name}")
+        spec.origin = self.full_path
         loader = CustomStoragesLoader(self.name)
         module = importlib.util.module_from_spec(spec)
         sys.modules[module_name] = module

+ 4 - 0
netbox/extras/models/models.py

@@ -9,6 +9,7 @@ from django.contrib.postgres.fields import ArrayField
 from django.core.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
+from django.db.models import FETCH_PEERS
 from django.urls import reverse
 from django.utils import timezone
 from django.utils.html import escape
@@ -572,6 +573,9 @@ class ExportTemplate(
 
     def get_context(self, context=None, queryset=None):
         _context = super().get_context(context=context, queryset=queryset)
+        # Fetch each related object the template reads for the whole queryset at once
+        if queryset is not None:
+            queryset = queryset.fetch_mode(FETCH_PEERS)
         _context['queryset'] = queryset
         return _context
 

+ 1 - 1
netbox/extras/tests/query_counts.json

@@ -3,7 +3,7 @@
   "bookmark:list_objects_with_permission": 16,
   "configcontext:api_list_objects": 22,
   "configcontext:list_objects_with_permission": 15,
-  "configcontextprofile:api_list_objects": 12,
+  "configcontextprofile:api_list_objects": 11,
   "configcontextprofile:list_objects_with_permission": 15,
   "configtemplate:api_list_objects": 10,
   "configtemplate:list_objects_with_permission": 16,

+ 3 - 5
netbox/extras/tests/test_api.py

@@ -2215,16 +2215,14 @@ class ScriptTestCase(APITestCase):
         )
         cls.url = reverse('extras-api:script-detail', kwargs={'pk': script.pk})
 
-    @property
-    def python_class(self):
-        return self.TestScriptClass
-
     def setUp(self):
         super().setUp()
         self.add_permissions('extras.view_script')
 
         # Monkey-patch the Script model to return our TestScriptClass above
-        Script.python_class = self.python_class
+        python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
+        python_class_patch.start()
+        self.addCleanup(python_class_patch.stop)
 
         # The script-run endpoint gates on a live RQ worker. Tests run without
         # one, so bypass the check to exercise validation and the enqueue path.

+ 9 - 0
netbox/extras/tests/test_filtersets.py

@@ -1294,6 +1294,15 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'location': [locations[0].slug, locations[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_location_shared_slug(self):
+        # A location slug shared by several sites should resolve to a single filter value
+        location = Location.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        Location.objects.create(site=site, name=location.name, slug=location.slug)
+        filterset = self.filterset({'location': [location.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['location']), 1)
+
     def test_device_type(self):
         device_types = DeviceType.objects.all()[:2]
         params = {'device_type_id': [device_types[0].pk, device_types[1].pk]}

+ 33 - 1
netbox/extras/tests/test_models.py

@@ -12,6 +12,7 @@ from django.core.files.base import ContentFile
 from django.core.files.storage import Storage
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.db import connection
+from django.db.models import FETCH_PEERS
 from django.forms import ValidationError
 from django.test import TestCase, override_settings, tag
 from django.test.utils import CaptureQueriesContext
@@ -1343,11 +1344,13 @@ class ExportTemplateContextTestCase(TestCase):
         self.assertIs(ctx['dcim']['Device'], Device)
 
     def test_get_context_includes_queryset(self):
+        Site.objects.create(name='Site 1', slug='site-1')
         et = ExportTemplate(name='test', template_code='test')
         qs = Site.objects.all()
         ctx = et.get_context(queryset=qs)
 
-        self.assertIs(ctx['queryset'], qs)
+        self.assertQuerySetEqual(ctx['queryset'], qs)
+        self.assertIs(ctx['queryset'][0]._state.fetch_mode, FETCH_PEERS)
 
     def test_get_context_applies_extra_context(self):
         et = ExportTemplate(name='test', template_code='test')
@@ -1581,6 +1584,35 @@ class ExportTemplateRenderTestCase(TestCase):
         output = t.render(queryset=queryset)
         self.assertEqual(output, 'Site A\nSite B\nSite C\n')
 
+    def test_render_batches_related_object_queries(self):
+        """
+        Each relation the template reads (including chained relations) costs one query for the whole queryset.
+        """
+        tenant_group = TenantGroup.objects.create(name='Tenant Group 1', slug='tenant-group-1')
+        for i, site in enumerate(Site.objects.order_by('name'), start=1):
+            site.tenant = Tenant.objects.create(name=f'Tenant {i}', slug=f'tenant-{i}', group=tenant_group)
+            site.save()
+
+        baseline = ExportTemplate(
+            name='names',
+            template_code='{% for obj in queryset %}{{ obj.name }}\n{% endfor %}',
+        )
+        t = ExportTemplate(
+            name='related',
+            template_code='{% for obj in queryset %}{{ obj }},{{ obj.tenant }},{{ obj.tenant.group }}\n{% endfor %}',
+        )
+        with CaptureQueriesContext(connection) as baseline_queries:
+            baseline.render(queryset=Site.objects.order_by('name'))
+        with CaptureQueriesContext(connection) as queries:
+            output = t.render(queryset=Site.objects.order_by('name'))
+
+        self.assertEqual(
+            output,
+            'Site A,Tenant 1,Tenant Group 1\nSite B,Tenant 2,Tenant Group 1\nSite C,Tenant 3,Tenant Group 1\n'
+        )
+        # One query for the tenants and one for their group
+        self.assertEqual(len(queries), len(baseline_queries) + 2)
+
     def test_render_to_response_for_queryset(self):
         t = ExportTemplate(
             name='sites',

+ 99 - 2
netbox/extras/tests/test_scripts.py

@@ -1,10 +1,14 @@
+import inspect
 import io
+import os
 import sys
+import traceback
 import uuid
 from datetime import UTC, date, datetime
 from decimal import Decimal
 from unittest.mock import PropertyMock, patch
 
+from django.conf import settings
 from django.contrib.auth import get_user_model
 from django.core.exceptions import ValidationError
 from django.core.files.uploadedfile import SimpleUploadedFile
@@ -477,6 +481,100 @@ class ScriptModuleLoadingTestCase(TestCase):
             script.log_success('Start')
         self.assertIn('Start', captured.output[0])
 
+    def test_module_file_is_filesystem_path_for_local_storage(self):
+        """
+        For the local file-system storage backend the display location must be the script's
+        actual filesystem path (<SCRIPTS_ROOT>/<file_path>), not a path derived from the
+        current working directory (#23511).
+        """
+        script_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class TestScript(Script):\n    pass\n"
+        )
+
+        class _Storage:
+            def open(self, name, mode='rb'):
+                return io.BytesIO(script_content)
+
+        module = ScriptModule(file_root='scripts', file_path='example.py')
+        namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}example'
+        self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
+
+        with patch('extras.models.mixins.storages') as mock_storages:
+            mock_storages.__getitem__.return_value = _Storage()
+            loaded = module.get_module()
+
+        self.assertEqual(loaded.__file__, module.full_path)
+        self.assertEqual(loaded.__file__, os.path.join(settings.SCRIPTS_ROOT, 'example.py'))
+        self.assertEqual(inspect.getfile(loaded.TestScript), module.full_path)
+
+    def test_module_file_is_storage_name_for_remote_storage(self):
+        """
+        For non-local storage backends (e.g. S3) the display location must be the storage name
+        (the key within the storage), not a local filesystem path (#23511).
+        """
+        script_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class TestScript(Script):\n    pass\n"
+        )
+
+        class _Storage:
+            # Deliberately not a ScriptFileSystemStorage: emulates a remote backend (e.g. S3)
+            def open(self, name, mode='rb'):
+                return io.BytesIO(script_content)
+
+        storage = _Storage()
+        module = ScriptModule(file_root='scripts', file_path='example.py')
+        namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}example'
+        self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
+
+        with (
+            patch('core.models.files.storages') as core_storages,
+            patch('extras.models.mixins.storages') as mock_storages,
+        ):
+            core_storages.backends = {'scripts': {}}
+            core_storages.create_storage.return_value = storage
+            mock_storages.__getitem__.return_value = storage
+            # No local root is prepended for non-local backends
+            self.assertEqual(module.full_path, 'example.py')
+            loaded = module.get_module()
+
+        self.assertEqual(loaded.__file__, 'example.py')
+
+    def test_code_objects_named_after_storage_location(self):
+        """
+        Code objects of a loaded script must be named after the script's storage location,
+        so tracebacks identify the script instead of showing '<string>' (#23511).
+        """
+        script_content = (
+            b"from extras.scripts import Script\n\n\n"
+            b"class TestScript(Script):\n"
+            b"    def run(self):\n"
+            b"        raise ValueError('boom')\n"
+        )
+
+        class _Storage:
+            def open(self, name, mode='rb'):
+                return io.BytesIO(script_content)
+
+        module = ScriptModule(file_root='scripts', file_path='example.py')
+        namespaced_key = f'{SCRIPT_MODULE_NAME_PREFIX}example'
+        self.addCleanup(lambda: sys.modules.pop(namespaced_key, None))
+
+        with patch('extras.models.mixins.storages') as mock_storages:
+            mock_storages.__getitem__.return_value = _Storage()
+            loaded = module.get_module()
+
+        self.assertEqual(loaded.TestScript.run.__code__.co_filename, module.full_path)
+
+        # The storage location also appears in tracebacks from script code
+        try:
+            loaded.TestScript().run()
+            self.fail('expected ValueError from script code')
+        except ValueError:
+            formatted = traceback.format_exc()
+        self.assertIn(f'File "{module.full_path}"', formatted)
+
 
 class ScriptMetaValidationTestCase(TestCase):
     """
@@ -710,11 +808,10 @@ class ScriptJobEnqueueValidationTestCase(TestCase):
 
         script = self._make_script(RecurringScript)
 
-        # Create a completed, recurring job as if a scheduled occurrence had just finished successfully.
+        # Create a waiting recurring job, which handle() runs to completion before it reschedules.
         job = Job.objects.create(
             object=script,
             name='Recurring',
-            status=JobStatusChoices.STATUS_COMPLETED,
             user=self.user,
             interval=60,
             job_id=uuid.uuid4(),

+ 107 - 2
netbox/extras/tests/test_views.py

@@ -1,5 +1,6 @@
 import datetime
 import hashlib
+import io
 import logging
 import uuid
 from unittest.mock import PropertyMock, patch
@@ -22,6 +23,7 @@ from extras.scripts import Script as PythonClass
 from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
 from users.models import Group, ObjectPermission, User
 from utilities.testing import TestCase, ViewTestCases
+from utilities.views import get_action_url
 
 
 class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
@@ -544,6 +546,55 @@ class ImageAttachmentTestCase(
             ]
         )
 
+    @tag('regression')
+    def test_add_form_submission_guard(self):
+        """The add form opts into the duplicate submission guard."""
+        self.add_permissions('extras.add_imageattachment')
+        site = Site.objects.get(slug='site-1')
+
+        response = self.client.get(reverse('extras:imageattachment_add'), {
+            'object_type': ContentType.objects.get_for_model(Site).pk,
+            'object_id': site.pk,
+        })
+
+        self.assertHttpStatus(response, 200)
+        self.assertIn(
+            '<form action="" method="post" enctype="multipart/form-data" '
+            'class="object-edit object-edit--with-sticky-actions mt-5" data-disable-on-submit>',
+            response.content.decode(),
+        )
+
+    def test_object_image_attachments_view_with_constrained_permission(self):
+        """The image attachments tab of an object lists only the attachments the user may view."""
+        self.add_permissions('dcim.view_site')
+        obj_perm = ObjectPermission(
+            name='View one image attachment',
+            constraints={'name': 'Image Attachment 1'},
+            actions=['view'],
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(ImageAttachment))
+        site = Site.objects.get(slug='site-1')
+
+        response = self.client.get(get_action_url(site, 'image-attachments', kwargs={'pk': site.pk}))
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(
+            [image.name for image in response.context['image_attachments']],
+            ['Image Attachment 1'],
+        )
+
+    def test_object_image_attachments_view_without_permission(self):
+        """A user who can view the object but no image attachments sees an empty list."""
+        self.add_permissions('dcim.view_site')
+        site = Site.objects.get(slug='site-1')
+
+        response = self.client.get(get_action_url(site, 'image-attachments', kwargs={'pk': site.pk}))
+
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(list(response.context['image_attachments']), [])
+
 
 class ExportTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = ExportTemplate
@@ -1509,7 +1560,9 @@ class ScriptValidationErrorTestCase(TestCase):
 
     def setUp(self):
         super().setUp()
-        Script.python_class = property(lambda self: ScriptValidationErrorTestCase.TestScriptClass)
+        python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
+        python_class_patch.start()
+        self.addCleanup(python_class_patch.stop)
 
     @tag('regression')
     def test_script_validation_error_displays_message(self):
@@ -1635,7 +1688,9 @@ class ScriptDefaultValuesTestCase(TestCase):
 
     def setUp(self):
         super().setUp()
-        Script.python_class = property(lambda self: ScriptDefaultValuesTestCase.TestScriptClass)
+        python_class_patch = patch.object(Script, 'python_class', new=self.TestScriptClass)
+        python_class_patch.start()
+        self.addCleanup(python_class_patch.stop)
 
     def test_default_values_are_used(self):
         url = reverse('extras:script', kwargs={'pk': self.script.pk})
@@ -1833,3 +1888,53 @@ class ScriptJobsRestrictionTestCase(TestCase):
     def test_without_view_job_permission(self):
         self._assert_script_views_show(set())
         self._assert_script_list_shows(None)
+
+
+class ScriptSourceViewTestCase(TestCase):
+    """
+    The Source tab must show the script's actual location in the scripts storage backend,
+    not a path derived from the current working directory (#23511).
+    """
+    user_permissions = ['extras.view_script']
+
+    SCRIPT_CONTENT = (
+        "from extras.scripts import Script\n\n\n"
+        "class TestScript(Script):\n    pass\n"
+    )
+
+    @classmethod
+    def setUpTestData(cls):
+        with patch.object(ScriptModule, 'sync_classes'):
+            cls.module = ScriptModule.objects.create(
+                file_root=ManagedFileRootPathChoices.SCRIPTS,
+                file_path='test_script.py',
+            )
+        cls.script = Script.objects.create(module=cls.module, name='TestScript', is_executable=True)
+
+    def setUp(self):
+        super().setUp()
+        content = self.SCRIPT_CONTENT.encode()
+
+        class _Storage:
+            def open(self, name, mode='rb'):
+                if 'b' in mode:
+                    return io.BytesIO(content)
+                return io.StringIO(content.decode())
+
+        self._module_storage_patch = patch('extras.models.mixins.storages')
+        self._script_storage_patch = patch('extras.scripts.storages')
+        module_storage = self._module_storage_patch.start()
+        script_storage = self._script_storage_patch.start()
+        module_storage.__getitem__.return_value = _Storage()
+        script_storage.backends = {'scripts': {}}
+        script_storage.create_storage.return_value = _Storage()
+        self.addCleanup(self._script_storage_patch.stop)
+        self.addCleanup(self._module_storage_patch.stop)
+
+    def test_source_view_shows_storage_location(self):
+        url = reverse('extras:script_source', kwargs={'pk': self.script.pk})
+        response = self.client.get(url)
+
+        self.assertHttpStatus(response, 200)
+        self.assertContains(response, self.module.full_path)
+        self.assertContains(response, 'class TestScript(Script):')

+ 1 - 1
netbox/ipam/api/views.py

@@ -41,7 +41,7 @@ class IPAMRootView(APIRootView):
 #
 
 class ASNRangeViewSet(NetBoxModelViewSet):
-    queryset = ASNRange.objects.all()
+    queryset = ASNRange.objects.annotate_asn_counts()
     serializer_class = serializers.ASNRangeSerializer
     filterset_class = filtersets.ASNRangeFilterSet
 

+ 22 - 7
netbox/ipam/filtersets.py

@@ -11,6 +11,7 @@ from circuits.models import Provider
 from dcim.base_filtersets import ScopedFilterSet
 from dcim.models import Device, Interface, Region, Site, SiteGroup
 from netbox.filtersets import (
+    BaseFilterSet,
     ChangeLoggedModelFilterSet,
     NetBoxModelFilterSet,
     OrganizationalModelFilterSet,
@@ -22,6 +23,7 @@ from utilities.filters import (
     MultiValueCharFilter,
     MultiValueContentTypeFilter,
     MultiValueNumberFilter,
+    NonUniqueModelMultipleChoiceFilter,
     TreeNodeMultipleChoiceFilter,
 )
 from utilities.filtersets import register_filterset
@@ -381,11 +383,13 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
     )
     present_in_vrf_id = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf_id',
         label=_('VRF')
     )
     present_in_vrf = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf',
         to_field_name='rd',
         label=_('VRF (RD)'),
@@ -397,7 +401,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, ScopedFilterSet, TenancyFilterSet,
         to_field_name='id',
         label=_('VLAN Group (ID)'),
     )
-    vlan_group = django_filters.ModelMultipleChoiceFilter(
+    vlan_group = NonUniqueModelMultipleChoiceFilter(
         field_name='vlan__group__slug',
         queryset=VLANGroup.objects.all(),
         distinct=False,
@@ -645,11 +649,13 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
     )
     present_in_vrf_id = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf_id',
         label=_('VRF')
     )
     present_in_vrf = django_filters.ModelChoiceFilter(
         queryset=VRF.objects.all(),
+        null_label=None,
         method='filter_present_in_vrf',
         to_field_name='rd',
         label=_('VRF (RD)'),
@@ -675,7 +681,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
         field_name='pk',
         label=_('Virtual machine (ID)'),
     )
-    interface = django_filters.ModelMultipleChoiceFilter(
+    interface = NonUniqueModelMultipleChoiceFilter(
         field_name='interface__name',
         queryset=Interface.objects.all(),
         to_field_name='name',
@@ -686,7 +692,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet, ContactModelFi
         queryset=Interface.objects.all(),
         label=_('Interface (ID)'),
     )
-    vminterface = django_filters.ModelMultipleChoiceFilter(
+    vminterface = NonUniqueModelMultipleChoiceFilter(
         field_name='vminterface__name',
         queryset=VMInterface.objects.all(),
         to_field_name='name',
@@ -843,6 +849,7 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
     )
     related_ip = django_filters.ModelMultipleChoiceFilter(
         queryset=IPAddress.objects.all(),
+        null_label=None,
         method='filter_related_ip'
     )
 
@@ -1032,12 +1039,14 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     related_to_site = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        null_label=None,
         method='filter_related_to_site',
         label=_('Related to site (ID)'),
     )
     # get_additional_lookups() skips filters with a method.
     related_to_site__n = django_filters.ModelMultipleChoiceFilter(
         queryset=Site.objects.all(),
+        null_label=None,
         method='filter_related_to_site_negated',
         label=_('Related to site (ID)'),
     )
@@ -1046,7 +1055,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         distinct=False,
         label=_('Group (ID)'),
     )
-    group = django_filters.ModelMultipleChoiceFilter(
+    group = NonUniqueModelMultipleChoiceFilter(
         field_name='group__slug',
         queryset=VLANGroup.objects.all(),
         distinct=False,
@@ -1072,18 +1081,22 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     available_at_site = django_filters.ModelChoiceFilter(
         queryset=Site.objects.all(),
+        null_label=None,
         method='get_for_site'
     )
     available_at_site_group = django_filters.ModelChoiceFilter(
         queryset=SiteGroup.objects.all(),
+        null_label=None,
         method='get_for_site_group'
     )
     available_on_device = django_filters.ModelChoiceFilter(
         queryset=Device.objects.select_related('cluster'),
+        null_label=None,
         method='get_for_device'
     )
     available_on_virtualmachine = django_filters.ModelChoiceFilter(
         queryset=VirtualMachine.objects.all(),
+        null_label=None,
         method='get_for_virtualmachine'
     )
     qinq_role = django_filters.MultipleChoiceFilter(
@@ -1112,11 +1125,13 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
     )
     interface_id = django_filters.ModelChoiceFilter(
         queryset=Interface.objects.all(),
+        null_label=None,
         method='filter_interface_id',
         label=_('Assigned interface')
     )
     vminterface_id = django_filters.ModelChoiceFilter(
         queryset=VMInterface.objects.all(),
+        null_label=None,
         method='filter_vminterface_id',
         label=_('Assigned VM interface')
     )
@@ -1242,7 +1257,7 @@ SERVICE_PORT_FILTERS = {
 }
 
 
-class ServicePortMappingFilterMixin(django_filters.FilterSet):
+class ServicePortMappingFilterMixin(BaseFilterSet):
     """
     Shared ``port_mappings``, ``protocol`` and ``port`` filtering for Service and ServiceTemplate, all
     operating on the ``port_mappings`` array. ``protocol`` and every active ``port`` lookup are
@@ -1312,7 +1327,7 @@ class ServicePortMappingFilterMixin(django_filters.FilterSet):
         """
         queryset = super().filter_queryset(queryset)
 
-        cleaned_data = self.form.cleaned_data
+        cleaned_data = self.cleaned_data
         protocols = cleaned_data.get('protocol') or []
         port_tests = [
             (lookup, values)
@@ -1361,7 +1376,7 @@ class ServicePortMappingFilterMixin(django_filters.FilterSet):
         # ?protocol=tcp&port__n=80 excludes only services exposing tcp/80 (not those exposing udp/80).
         if not value:
             return queryset
-        protocols = self.form.cleaned_data.get('protocol') or []
+        protocols = self.cleaned_data.get('protocol') or []
         return queryset.exclude(port_mapping_q(protocols, [('exact', value)]))
 
 

+ 62 - 0
netbox/ipam/tests/test_api.py

@@ -84,6 +84,68 @@ class ASNRangeTestCase(APIViewTestCases.APIViewTestCase):
             },
         ]
 
+    def test_asn_count_matches_registered_asns(self):
+        rir = RIR.objects.first()
+
+        asns = (
+            ASN(asn=110, rir=rir),
+            ASN(asn=120, rir=rir),
+            ASN(asn=250, rir=rir),
+            ASN(asn=65000, rir=rir),
+        )
+        ASN.objects.bulk_create(asns)
+
+        self.add_permissions('ipam.view_asnrange')
+
+        url = reverse('ipam-api:asnrange-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        counts = {
+            item['name']: item['asn_count']
+            for item in response.data['results']
+        }
+
+        self.assertEqual(counts['ASN Range 1'], 2)
+        self.assertEqual(counts['ASN Range 2'], 1)
+
+    def test_asn_count_in_list_response(self):
+        self.add_permissions('ipam.view_asnrange')
+
+        url = reverse('ipam-api:asnrange-list')
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+
+        results = response.data['results']
+
+        self.assertGreater(len(results), 0)
+
+        for asn_range in results:
+            self.assertIn('asn_count', asn_range)
+
+    def test_asn_count_in_detail_response(self):
+        asn_range = ASNRange.objects.get(slug='asn-range-1')
+
+        ASN.objects.bulk_create([
+            ASN(asn=110, rir=asn_range.rir),
+            ASN(asn=120, rir=asn_range.rir),
+        ])
+
+        self.add_permissions('ipam.view_asnrange')
+
+        url = reverse(
+            'ipam-api:asnrange-detail',
+            kwargs={'pk': asn_range.pk}
+        )
+
+        response = self.client.get(url, **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        self.assertIn('asn_count', response.data)
+        self.assertEqual(response.data['asn_count'], 2)
+
     def test_list_available_asns(self):
         """
         Test retrieval of all available ASNs within a parent range.

+ 87 - 0
netbox/ipam/tests/test_filtersets.py

@@ -862,6 +862,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(self.filterset({'present_in_vrf': vrf1.rd}, self.queryset).qs.count(), 6)
         self.assertEqual(self.filterset({'present_in_vrf': vrf2.rd}, self.queryset).qs.count(), 2)
 
+        # Reject invalid filter values
+        for params in ({'present_in_vrf_id': 'null'}, {'present_in_vrf': 'null'}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_region(self):
         regions = Region.objects.all()[:2]
         params = {'region_id': [regions[0].pk, regions[1].pk]}
@@ -890,6 +894,15 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vlan_group': [vlan_groups[0].slug, vlan_groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_vlan_group_shared_slug(self):
+        # A VLAN group slug shared by several scopes should resolve to a single filter value
+        group = VLANGroup.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        VLANGroup.objects.create(scope=site, name=group.name, slug=group.slug)
+        filterset = self.filterset({'vlan_group': [group.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['vlan_group']), 1)
+
     def test_vlan(self):
         vlans = VLAN.objects.all()[:2]
         params = {'vlan_id': [vlans[0].pk, vlans[1].pk]}
@@ -1439,6 +1452,22 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vrf': [vrfs[0].rd, vrfs[1].rd]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_present_in_vrf(self):
+        """Match addresses in the VRF or in a VRF exporting one of its import targets."""
+        vrf1 = VRF.objects.get(name='VRF 1')
+        vrf2 = VRF.objects.get(name='VRF 2')
+        route_target = RouteTarget.objects.create(name='65000:200')
+        vrf1.import_targets.add(route_target)
+        vrf2.export_targets.add(route_target)
+        self.assertEqual(self.filterset({'present_in_vrf_id': vrf1.pk}, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset({'present_in_vrf_id': vrf2.pk}, self.queryset).qs.count(), 2)
+        self.assertEqual(self.filterset({'present_in_vrf': vrf1.rd}, self.queryset).qs.count(), 4)
+        self.assertEqual(self.filterset({'present_in_vrf': vrf2.rd}, self.queryset).qs.count(), 2)
+
+        # Reject invalid filter values
+        for params in ({'present_in_vrf_id': 'null'}, {'present_in_vrf': 'null'}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_device(self):
         devices = Device.objects.all()[:2]
         params = {'device_id': [devices[0].pk, devices[1].pk]}
@@ -1467,6 +1496,19 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'vminterface': ['Interface 1', 'Interface 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+    def test_interface_shared_name(self):
+        # A name shared by several interfaces should resolve to a single filter value
+        Interface.objects.create(
+            device=Device.objects.get(name='Device 2'), name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED
+        )
+        VMInterface.objects.create(
+            virtual_machine=VirtualMachine.objects.get(name='Virtual Machine 2'), name='Interface 1'
+        )
+        for param in ('interface', 'vminterface'):
+            filterset = self.filterset({param: ['Interface 1']}, self.queryset)
+            self.assertEqual(filterset.qs.count(), 1)
+            self.assertEqual(len(filterset.form.cleaned_data[param]), 1)
+
     def test_fhrpgroup(self):
         fhrp_groups = FHRPGroup.objects.all()[:2]
         params = {'fhrpgroup_id': [fhrp_groups[0].pk, fhrp_groups[1].pk]}
@@ -1598,6 +1640,10 @@ class FHRPGroupTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'related_ip': [ipaddresses[0].pk, ipaddresses[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
+        # Reject invalid filter values
+        for params in ({'related_ip': ['null']}, {'related_ip': [ipaddresses[0].pk, 'null']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
 
 class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
     queryset = FHRPGroupAssignment.objects.all()
@@ -2274,6 +2320,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
             [2001, 2002, 2003, 3001, 3002, 3003]
         )
 
+        # Reject invalid filter values
+        for params in ({'related_to_site': ['null']}, {'related_to_site': [site.pk, 'null']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_related_to_site_matches_any_of_several_sites(self):
         """Several sites spanning two site groups are ORed together, and a shared VLAN comes back once."""
         sites = (
@@ -2343,6 +2393,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'related_to_site__n': [site.pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), self.queryset.count() - 2)
 
+        # Reject invalid filter values
+        for params in ({'related_to_site__n': ['null']}, {'related_to_site__n': [site.pk, 'null']}):
+            self.assertFalse(self.filterset(params, self.queryset).is_valid(), msg=params)
+
     def test_related_to_site_resolves_site_groups_in_constant_queries(self):
         """The query count is constant in the number of sites, and drops to one without groups."""
         sites = list(Site.objects.filter(slug__in=('site-1', 'site-2', 'site-3')))
@@ -2394,6 +2448,15 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'group': [groups[0].slug, groups[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
 
+    def test_group_shared_slug(self):
+        # A VLAN group slug shared by several scopes should resolve to a single filter value
+        group = VLANGroup.objects.first()
+        site = Site.objects.create(name='Site 99', slug='site-99')
+        VLANGroup.objects.create(scope=site, name=group.name, slug=group.slug)
+        filterset = self.filterset({'group': [group.slug]}, self.queryset)
+        self.assertTrue(filterset.is_valid())
+        self.assertEqual(len(filterset.form.cleaned_data['group']), 1)
+
     def test_description(self):
         params = {'description': ['foobar1', 'foobar2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2428,6 +2491,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'available_on_device': device_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)  # 5 scoped + 1 global group + 1 global
 
+        # Reject invalid filter values
+        params = {'available_on_device': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_available_on_device_cluster_scopes(self):
         device = Device.objects.get(name='Device 1')
         device.cluster = Cluster.objects.get(name='Cluster 1')
@@ -2456,16 +2523,28 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         params = {'available_on_virtualmachine': vm_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)  # 6 scoped + 1 global group + 1 global
 
+        # Reject invalid filter values
+        params = {'available_on_virtualmachine': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_available_at_site(self):
         site_id = Site.objects.first().pk
         params = {'available_at_site': site_id}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)  # 4 scoped + 1 global group + 1 global
 
+        # Reject invalid filter values
+        params = {'available_at_site': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_available_at_site_group(self):
         site_group = SiteGroup.objects.get(name='Site Group 1')
         params = {'available_at_site_group': site_group.pk}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)  # 1 scoped + 1 global group + 1 global
 
+        # Reject invalid filter values
+        params = {'available_at_site_group': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_interface(self):
         interface_id = Interface.objects.first().pk
         params = {'interface_id': interface_id}
@@ -2491,6 +2570,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(qs.count(), 2)
         self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
 
+        # Reject invalid filter values
+        params = {'interface_id': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_vminterface(self):
         vminterface_id = VMInterface.objects.first().pk
         params = {'vminterface_id': vminterface_id}
@@ -2515,6 +2598,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTestMixin):
         self.assertEqual(qs.count(), 2)
         self.assertEqual(len(qs), len(set(qs.values_list('pk', flat=True))))
 
+        # Reject invalid filter values
+        params = {'vminterface_id': 'null'}
+        self.assertFalse(self.filterset(params, self.queryset).is_valid())
+
     def test_qinq_role(self):
         params = {'qinq_role': [VLANQinQRoleChoices.ROLE_SERVICE, VLANQinQRoleChoices.ROLE_CUSTOMER]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)

+ 49 - 5
netbox/ipam/tests/test_views.py

@@ -3,7 +3,7 @@ from unittest.mock import patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.db.backends.postgresql.psycopg_any import NumericRange
-from django.test import RequestFactory, override_settings
+from django.test import RequestFactory, override_settings, tag
 from django.urls import reverse
 from netaddr import IPNetwork
 
@@ -989,6 +989,50 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         for objectchange in objectchanges:
             self.assertEqual(objectchange.message, changelog_message)
 
+    @tag('regression')  # Issue #23585
+    def test_bulk_add_prefixes_excludes_restricted_vrfs(self):
+        """
+        The prefix bulk-add form only displays and accepts VRFs the user can view.
+        """
+        visible_vrf = VRF.objects.get(name='VRF 1')
+        hidden_vrf = VRF.objects.get(name='VRF 2')
+
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['view'],
+            constraints={'pk': visible_vrf.pk},
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(VRF))
+        self.add_permissions('ipam.add_prefix')
+        url = reverse('ipam:prefix_bulk_add')
+        data = {
+            'pattern': '198.18.[40-41].0/24',
+            'vrf': hidden_vrf.pk,
+            'status': PrefixStatusChoices.STATUS_ACTIVE,
+        }
+
+        # Creation rejects the hidden VRF
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 200)
+        self.assertNotContains(response, hidden_vrf.name)
+        self.assertFalse(Prefix.objects.filter(prefix=IPNetwork('198.18.40.0/24')).exists())
+
+        # Neither the form nor its HTMX re-render displays the hidden VRF
+        for headers in ({}, {'HX-Request': 'true'}):
+            with self.subTest(headers=headers):
+                response = self.client.get(url, {'vrf': hidden_vrf.pk}, headers=headers)
+                self.assertHttpStatus(response, 200)
+                self.assertNotContains(response, hidden_vrf.name)
+
+        # A visible VRF is accepted
+        data['vrf'] = visible_vrf.pk
+        response = self.client.post(url, data)
+        self.assertHttpStatus(response, 302)
+        for i in (40, 41):
+            self.assertTrue(Prefix.objects.filter(prefix=IPNetwork(f'198.18.{i}.0/24'), vrf=visible_vrf).exists())
+
     def test_prefix_prefixes(self):
         self.add_permissions('ipam.view_prefix')
         prefixes = (
@@ -2460,7 +2504,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
     def test_bulk_add_vlans(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
         group = VLANGroup.objects.get(name='VLAN Group 1')
         initial_count = VLAN.objects.count()
@@ -2488,7 +2532,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             )
 
     def test_bulk_add_vlans_rolls_back_on_duplicate_name(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
         group = VLANGroup.objects.get(name='VLAN Group 1')
         initial_count = VLAN.objects.count()
@@ -2507,7 +2551,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertFalse(VLAN.objects.filter(group=group, vid=110).exists())
 
     def test_bulk_add_vlans_rolls_back_when_any_id_outside_group_range(self):
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
 
         group = VLANGroup.objects.create(
             name='Restricted VLAN Group',
@@ -2533,7 +2577,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
     def test_bulk_add_vlans_pattern_shapes(self):
         """Single values, multiple values, ranges, and combinations create the expected VLANs."""
-        self.add_permissions('ipam.add_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlangroup')
         # The combination runs against a second group: subTests share one transaction, and VIDs
         # 10 & 20 would otherwise collide with the multiple-values case via the (group, vid) constraint.
         cases = (

+ 4 - 9
netbox/ipam/views.py

@@ -573,11 +573,8 @@ class ChildAvailabilityMixin:
     def _get_detection_filterset(self, request):
         """
         Return the bound FilterSet used to evaluate the request, or None if the view declares no
-        filterset. ObjectChildrenView.get() has already built one and validated its form while
-        resolving the child queryset, so reuse it rather than paying for a second FilterSet:
-        get_filters() regenerates every dynamic lookup variant and NetBoxModelFilterSet.__init__
-        queries the custom fields for the model. Build one only for direct calls where get() has
-        not run.
+        filterset. Reuse the one ObjectChildrenView.get() built while resolving the child queryset,
+        if any.
         """
         if self.filterset_instance is not None:
             return self.filterset_instance
@@ -600,7 +597,7 @@ class ChildAvailabilityMixin:
 
         self._active_child_filters = False
 
-        # An empty request cannot activate a filter, so skip validating a form for nothing.
+        # An empty request cannot activate a filter.
         if not request.GET:
             return False
 
@@ -610,9 +607,7 @@ class ChildAvailabilityMixin:
 
         # A non-empty cleaned value means the request activated a declared filter. Emptiness
         # follows each filter's own semantics, so absent multi-value fields stay inactive.
-        # This is a no-op for a reused FilterSet: .qs validated the form to build the queryset.
-        filterset.form.is_valid()
-        for name, value in filterset.form.cleaned_data.items():
+        for name, value in filterset.cleaned_data.items():
             if isinstance(filterset.filters[name], django_filters.MultipleChoiceFilter):
                 if value:  # mirrors MultipleChoiceFilter.filter()
                     self._active_child_filters = True

+ 57 - 0
netbox/netbox/api/filters.py

@@ -0,0 +1,57 @@
+from rest_framework import filters
+
+from netbox.api.fields import RelatedObjectCountField
+from utilities.api import get_annotations_for_serializer
+
+__all__ = (
+    'OrderingFilter',
+)
+
+
+class OrderingFilter(filters.OrderingFilter):
+    """
+    Extends DRF's OrderingFilter to order by a RelatedObjectCountField under its field name rather than its source,
+    annotating the count when the response leaves it out, since its annotation is otherwise applied only when the field
+    is included in the response.
+    """
+    @staticmethod
+    def _get_serializer_class(view):
+        if hasattr(view, 'get_serializer_class'):
+            return view.get_serializer_class()
+        return view.serializer_class
+
+    def _get_count_sources(self, view):
+        """Map the name of each RelatedObjectCountField on the view's serializer to its source."""
+        return {
+            field_name: field.source or field_name
+            for field_name, field in self._get_serializer_class(view)._declared_fields.items()
+            if isinstance(field, RelatedObjectCountField)
+        }
+
+    def get_default_valid_fields(self, queryset, view, context=None):
+        field_names = {source: field_name for field_name, source in self._get_count_sources(view).items()}
+        return [
+            (field_names.get(key, key), label)
+            for key, label in super().get_default_valid_fields(queryset, view, context)
+        ]
+
+    def filter_queryset(self, request, queryset, view):
+        ordering = self.get_ordering(request, queryset, view)
+        if not ordering:
+            return queryset
+
+        count_sources = self._get_count_sources(view)
+        terms = []
+        missing = []
+        for term in ordering:
+            if isinstance(term, str) and (field_name := term.lstrip('-')) in count_sources:
+                source = count_sources[field_name]
+                if source not in queryset.query.annotations:
+                    missing.append(field_name)
+                term = f'-{source}' if term.startswith('-') else source
+            terms.append(term)
+        if missing:
+            serializer_class = self._get_serializer_class(view)
+            queryset = queryset.annotate(**get_annotations_for_serializer(serializer_class, fields=missing))
+
+        return queryset.order_by(*terms)

+ 1 - 1
netbox/netbox/api/viewsets/__init__.py

@@ -188,7 +188,7 @@ class BaseViewSet(GenericViewSet):
         """Return the names of the count fields annotated for reads only."""
         return frozenset(
             name for name, field in self.get_serializer_class()._declared_fields.items()
-            if type(field) is RelatedObjectCountField and field.fallback_source
+            if isinstance(field, RelatedObjectCountField) and field.fallback_source
         )
 
 

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików