Просмотр исходного кода

Merge branch 'main' into feature

Jeremy Stretch 4 часов назад
Родитель
Сommit
a082106bbe
100 измененных файлов с 4720 добавлено и 440 удалено
  1. 164 0
      .claude/skills/draft-release-notes/SKILL.md
  2. 168 0
      .claude/skills/draft-release-notes/audit_milestone.py
  3. 83 0
      .claude/skills/draft-release-notes/render_release_notes.py
  4. 283 0
      .claude/skills/prepare-release/SKILL.md
  5. 164 0
      .claude/skills/publish-release/SKILL.md
  6. 70 0
      .claude/skills/publish-release/pypi-recovery.md
  7. 7 0
      .github/VOUCHED.td
  8. 1 1
      .github/workflows/ci.yml
  9. 30 0
      .github/workflows/enforce-issue-labels.yml
  10. 0 21
      .github/workflows/no-blank-issue.yml
  11. 1 1
      .pre-commit-config.yaml
  12. 1 0
      AGENTS.md
  13. 1 1
      CHANGELOG.md
  14. 2 2
      CONTRIBUTING.md
  15. 18 11
      README.md
  16. 2 1
      base_requirements.txt
  17. 1 1
      contrib/netbox-rq.service
  18. 1 1
      contrib/netbox.service
  19. 46 25
      contrib/openapi.json
  20. 0 7
      docs/development/application-registry.md
  21. 3 0
      docs/development/release-checklist.md
  22. 42 1
      docs/features/context-data.md
  23. 57 15
      docs/index.md
  24. 1 1
      docs/installation/4a-gunicorn.md
  25. 1 1
      docs/installation/4b-uwsgi.md
  26. BIN
      docs/media/misc/reference_architecture.png
  27. 1 1
      docs/models/dcim/module.md
  28. 1 1
      docs/models/extras/configcontext.md
  29. 40 0
      docs/release-notes/version-4.7.md
  30. 1 1
      mkdocs.yml
  31. 27 0
      netbox/account/tests.py
  32. 34 0
      netbox/circuits/migrations/0061_circuitterminations_cached_ancestors_on_delete.py
  33. 1 1
      netbox/circuits/migrations/0062_generated_fields.py
  34. 11 2
      netbox/circuits/models/circuits.py
  35. 66 1
      netbox/circuits/tests/test_models.py
  36. 0 1
      netbox/core/filtersets.py
  37. 1 1
      netbox/core/models/data.py
  38. 38 17
      netbox/core/models/jobs.py
  39. 1 1
      netbox/core/tables/template_code.py
  40. 4 2
      netbox/core/tests/test_api.py
  41. 14 1
      netbox/core/tests/test_filtersets.py
  42. 38 2
      netbox/core/tests/test_jobs.py
  43. 159 1
      netbox/core/tests/test_models.py
  44. 256 3
      netbox/core/tests/test_views.py
  45. 51 2
      netbox/core/utils.py
  46. 14 11
      netbox/dcim/api/serializers_/devices.py
  47. 38 16
      netbox/dcim/api/views.py
  48. 28 0
      netbox/dcim/cable_profiles.py
  49. 6 0
      netbox/dcim/choices.py
  50. 15 13
      netbox/dcim/filtersets.py
  51. 22 13
      netbox/dcim/forms/common.py
  52. 8 3
      netbox/dcim/forms/mixins.py
  53. 39 0
      netbox/dcim/migrations/0245_consolidate_unique_constraints.py
  54. 3 0
      netbox/dcim/models/cables.py
  55. 1 1
      netbox/dcim/models/device_component_templates.py
  56. 31 0
      netbox/dcim/models/device_components.py
  57. 12 7
      netbox/dcim/models/devices.py
  58. 24 11
      netbox/dcim/models/module_moves.py
  59. 20 4
      netbox/dcim/models/modules.py
  60. 12 0
      netbox/dcim/models/sites.py
  61. 5 1
      netbox/dcim/signals.py
  62. 2 2
      netbox/dcim/tests/query_counts.json
  63. 412 29
      netbox/dcim/tests/test_api.py
  64. 51 0
      netbox/dcim/tests/test_cablepaths.py
  65. 130 3
      netbox/dcim/tests/test_channelization.py
  66. 92 0
      netbox/dcim/tests/test_filtersets.py
  67. 133 0
      netbox/dcim/tests/test_forms.py
  68. 37 0
      netbox/dcim/tests/test_models.py
  69. 38 1
      netbox/dcim/tests/test_module_moves.py
  70. 73 0
      netbox/dcim/tests/test_port_mappings.py
  71. 256 3
      netbox/dcim/tests/test_views.py
  72. 25 1
      netbox/dcim/tests/utils.py
  73. 33 13
      netbox/dcim/utils.py
  74. 8 7
      netbox/dcim/views.py
  75. 2 1
      netbox/extras/filtersets.py
  76. 2 1
      netbox/extras/models/mixins.py
  77. 4 0
      netbox/extras/models/models.py
  78. 3 5
      netbox/extras/tests/test_api.py
  79. 9 0
      netbox/extras/tests/test_filtersets.py
  80. 33 1
      netbox/extras/tests/test_models.py
  81. 99 2
      netbox/extras/tests/test_scripts.py
  82. 107 2
      netbox/extras/tests/test_views.py
  83. 1 1
      netbox/ipam/api/views.py
  84. 18 4
      netbox/ipam/filtersets.py
  85. 62 0
      netbox/ipam/tests/test_api.py
  86. 87 0
      netbox/ipam/tests/test_filtersets.py
  87. 49 5
      netbox/ipam/tests/test_views.py
  88. 57 0
      netbox/netbox/api/filters.py
  89. 1 1
      netbox/netbox/api/viewsets/__init__.py
  90. 9 4
      netbox/netbox/graphql/types.py
  91. 77 63
      netbox/netbox/jobs.py
  92. 3 31
      netbox/netbox/search/backends.py
  93. 9 1
      netbox/netbox/settings.py
  94. 0 3
      netbox/netbox/tables/tables.py
  95. 70 0
      netbox/netbox/tests/test_authentication.py
  96. 67 1
      netbox/netbox/tests/test_graphql.py
  97. 394 22
      netbox/netbox/tests/test_jobs.py
  98. 51 1
      netbox/netbox/tests/test_search.py
  99. 44 24
      netbox/netbox/tests/test_views.py
  100. 3 0
      netbox/netbox/views/generic/bulk_views.py

+ 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.

+ 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.

+ 2 - 1
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

+ 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": [

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

@@ -30,13 +30,6 @@ A dictionary of model features (e.g. custom fields, tags, etc.) mapped to the fu
 
 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
 

+ 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)

+ 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:

+ 38 - 17
netbox/core/models/jobs.py

@@ -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 _
@@ -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 = (

+ 159 - 1
netbox/core/tests/test_models.py

@@ -17,6 +17,7 @@ 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
@@ -276,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):
 
@@ -356,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.
         """
@@ -366,6 +382,7 @@ class JobTestCase(TestCase):
             user=user,
             notifications=notifications,
             status=JobStatusChoices.STATUS_RUNNING,
+            **kwargs,
         )
 
     @patch('core.models.jobs.django_rq.get_queue')
@@ -400,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):
         """
@@ -472,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):
         """

+ 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')
 

+ 51 - 2
netbox/core/utils.py

@@ -1,7 +1,9 @@
 import logging
+import uuid
+from functools import partial
 
 from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist
-from django.db import DatabaseError, connection
+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 _
@@ -20,6 +22,7 @@ from rq.registry import (
     StartedJobRegistry,
 )
 
+from core.choices import JobStatusChoices
 from netbox.registry import registry
 
 __all__ = (
@@ -104,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.
@@ -117,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):
@@ -133,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

+ 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,

+ 256 - 3
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

+ 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):

+ 8 - 7
netbox/dcim/views.py

@@ -5224,6 +5224,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 +5237,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()
 

+ 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',

+ 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
 

+ 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
 

+ 18 - 4
netbox/ipam/filtersets.py

@@ -23,6 +23,7 @@ from utilities.filters import (
     MultiValueCharFilter,
     MultiValueContentTypeFilter,
     MultiValueNumberFilter,
+    NonUniqueModelMultipleChoiceFilter,
     TreeNodeMultipleChoiceFilter,
 )
 from utilities.filtersets import register_filterset
@@ -382,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)'),
@@ -398,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,
@@ -646,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)'),
@@ -676,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',
@@ -687,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',
@@ -844,6 +849,7 @@ class FHRPGroupFilterSet(PrimaryModelFilterSet):
     )
     related_ip = django_filters.ModelMultipleChoiceFilter(
         queryset=IPAddress.objects.all(),
+        null_label=None,
         method='filter_related_ip'
     )
 
@@ -1033,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)'),
     )
@@ -1047,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,
@@ -1073,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(
@@ -1113,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')
     )

+ 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 = (

+ 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
         )
 
 

+ 9 - 4
netbox/netbox/graphql/types.py

@@ -3,7 +3,7 @@ from copy import copy
 import strawberry
 import strawberry_django
 from django.contrib.contenttypes.models import ContentType
-from django.db.models import BinaryField, ExpressionWrapper, F, Func, IntegerField, Value
+from django.db.models import FETCH_PEERS, BinaryField, ExpressionWrapper, F, Func, IntegerField, Value
 from strawberry.types import Info
 from strawberry_django.optimizer import OptimizerStore
 
@@ -41,7 +41,7 @@ def _model_type(model, **kwargs):
     """
     Wrap `strawberry_django.type()` to give each type's `display` field an optimizer hint. `display` resolves via
     `__str__()`, which may read any of the model's columns; without a hint the optimizer defers them, and reading a
-    deferred column reloads the row with one query per object.
+    deferred column costs an extra query.
     """
     def wrapper(cls):
         graphql_type = strawberry_django.type(model, **kwargs)(cls)
@@ -66,14 +66,19 @@ def _model_type(model, **kwargs):
 @strawberry.type
 class BaseObjectType:
     """
-    Base GraphQL object type for all NetBox objects. Restricts the model queryset to enforce object permissions.
+    Base GraphQL object type for all NetBox objects. Enforces object permissions and batches deferred field and
+    relation loads.
     """
 
     @classmethod
     def get_queryset(cls, queryset, info: Info, **kwargs):
         # Enforce object permissions on the queryset
         if hasattr(queryset, 'restrict'):
-            return queryset.restrict(info.context.request.user, 'view')
+            queryset = queryset.restrict(info.context.request.user, 'view')
+        # Batch the loading of deferred fields and relations which the optimizer can't anticipate. Skip a queryset
+        # already evaluated by a prefetch_related hint (e.g. tags): cloning it would discard the prefetched results.
+        if queryset._result_cache is None:
+            queryset = queryset.fetch_mode(FETCH_PEERS)
         return queryset
 
     @strawberry_django.field

+ 77 - 63
netbox/netbox/jobs.py

@@ -3,6 +3,7 @@ import logging
 import os
 import traceback
 from abc import ABC, abstractmethod
+from contextlib import ExitStack
 from datetime import timedelta
 from io import BytesIO
 from pathlib import Path
@@ -115,17 +116,25 @@ class JobRunner(ABC):
         job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`.
         """
         logger = logging.getLogger('netbox.jobs')
+        skipped = False
+        status = None
+        error = None
 
         try:
-            job.start()
+            if not job.start():
+                skipped = True
+                logger.warning(f"Skipping job {job} (PK: {job.pk}): it could not be claimed for execution")
+                return
             cls(job).run(*args, **kwargs)
-            job.terminate()
+            status = JobStatusChoices.STATUS_COMPLETED
 
         except JobFailed:
+            status = JobStatusChoices.STATUS_FAILED
             logger.warning(f"Job {job} failed")
-            job.terminate(status=JobStatusChoices.STATUS_FAILED)
 
         except Exception as e:
+            status = JobStatusChoices.STATUS_ERRORED
+            error = repr(e)
             tb_str = traceback.format_exc().replace(_INSTALL_ROOT, '')
             tb_record = logging.makeLogRecord({
                 'levelno': logging.ERROR,
@@ -133,71 +142,76 @@ class JobRunner(ABC):
                 'msg': tb_str,
             })
             job.log(tb_record)
-            job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
             if type(e) is JobTimeoutException:
                 logger.error(e)
 
-        # If the executed job is a periodic job, schedule its next execution at the specified interval.
+        # Keep the current job running until the rescheduling attempt finishes (see #23535)
         finally:
-            if job.interval:
-                # Determine the new scheduled time. Cannot be earlier than one minute in the future.
-                new_scheduled_time = max(
-                    (job.scheduled or job.started) + timedelta(minutes=job.interval),
-                    timezone.now() + timedelta(minutes=1)
-                )
-                if job.object and getattr(job.object, "python_class", None):
-                    kwargs["job_timeout"] = job.object.python_class.job_timeout
-
-                enqueue_kwargs = dict(
-                    instance=job.object,
-                    name=job.name,
-                    user=job.user,
-                    schedule_at=new_scheduled_time,
+            terminated = False
+            try:
+                with ExitStack() as stack:
+                    try:
+                        if job.interval and job.started and not skipped:
+                            if cls in registry['system_jobs']:
+                                # Held through the terminal save, so overlapping runs cannot both skip rescheduling
+                                stack.enter_context(advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']))
+                            cls._reschedule(job, **kwargs)
+                    finally:
+                        if status is not None:
+                            job.terminate(status=status, error=error, dispatch=False)
+                            terminated = True
+            finally:
+                # Outside the schedule lock, so slow job_end receivers cannot delay enqueue_once() callers
+                if terminated:
+                    job._dispatch_termination()
+
+    @classmethod
+    def _reschedule(cls, job, **kwargs):
+        """
+        Schedule the next occurrence of a periodic job. For a system job, the caller must hold the schedule lock.
+        """
+        logger = logging.getLogger('netbox.jobs')
+
+        # Determine the new scheduled time. Cannot be earlier than one minute in the future.
+        new_scheduled_time = max(
+            (job.scheduled or job.started) + timedelta(minutes=job.interval),
+            timezone.now() + timedelta(minutes=1)
+        )
+        if job.object and getattr(job.object, 'python_class', None):
+            kwargs['job_timeout'] = job.object.python_class.job_timeout
+
+        try:
+            # Only system jobs are deduplicated, as scheduled scripts may run parallel schedules (see #22232)
+            if cls in registry['system_jobs']:
+                successor_exists = Job.objects.filter(
+                    name=cls.name,
+                    object_id__isnull=True,
+                    status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
                     interval=job.interval,
-                    notifications=job.notifications,
-                    **kwargs,
-                )
-
-                # Reschedule the next occurrence. If the object's configuration has become invalid since this run was
-                # scheduled (e.g. a script's Meta.job_timeout was edited to an invalid value, see #22872), the enqueue
-                # will raise a ValidationError. Record it on this job and decline to reschedule rather than allowing an
-                # unhandled exception to escape the worker's finally block.
-                try:
-                    if cls in registry['system_jobs']:
-                        # System jobs are also scheduled by `enqueue_once()` at worker startup,
-                        # which races with this finally block and can produce duplicate schedules
-                        # (see #22232). Acquire the same advisory lock used by `enqueue_once()`
-                        # and skip rescheduling if a successor is already enqueued.
-                        #
-                        # This branch is limited to system jobs because generic recurring jobs
-                        # (e.g. scheduled scripts) may have multiple legitimate schedules sharing
-                        # the same runner/object/interval but differing in their runtime kwargs.
-                        with advisory_lock(ADVISORY_LOCK_KEYS['job-schedules']):
-                            successor_exists = Job.objects.filter(
-                                name=cls.name,
-                                object_id__isnull=True,
-                                status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
-                                interval=job.interval,
-                            ).exclude(pk=job.pk).exists()
-                            if not successor_exists:
-                                cls.enqueue(**enqueue_kwargs)
-                    else:
-                        cls.enqueue(**enqueue_kwargs)
-                except ValidationError as e:
-                    # The successor could not be scheduled because the object's configuration is now invalid. Record
-                    # this against the (already-terminated) job without overwriting the outcome of the run that just
-                    # completed — re-running terminate() here would clobber a successful run's status and fire a
-                    # duplicate notification (see #22872).
-                    error = _("Recurring job not rescheduled due to invalid configuration: {error}").format(
-                        error='; '.join(e.messages)
-                    )
-                    logger.error(f"Job {job}: {error}")
-                    job.log(logging.makeLogRecord({
-                        'levelno': logging.ERROR,
-                        'levelname': 'ERROR',
-                        'msg': error,
-                    }))
-                    job.save()
+                ).exclude(pk=job.pk).exists()
+                if successor_exists:
+                    return
+            cls.enqueue(
+                instance=job.object,
+                name=job.name,
+                user=job.user,
+                schedule_at=new_scheduled_time,
+                interval=job.interval,
+                notifications=job.notifications,
+                **kwargs,
+            )
+        except ValidationError as e:
+            # Keep the run's outcome when invalid configuration blocks the reschedule (see #22872)
+            message = _("Recurring job not rescheduled due to invalid configuration: {error}").format(
+                error='; '.join(e.messages)
+            )
+            logger.error(f"Job {job}: {message}")
+            job.log(logging.makeLogRecord({
+                'levelno': logging.ERROR,
+                'levelname': 'ERROR',
+                'msg': message,
+            }))
+            job.save()
 
     @classmethod
     def get_jobs(cls, instance=None):

+ 3 - 31
netbox/netbox/search/backends.py

@@ -5,8 +5,7 @@ import netaddr
 from django.conf import settings
 from django.core.exceptions import ImproperlyConfigured
 from django.db import DatabaseError, ProgrammingError, transaction
-from django.db.models import F, Q, Window, prefetch_related_objects
-from django.db.models.fields.related import ForeignKey
+from django.db.models import FETCH_PEERS, F, Q, Window
 from django.db.models.functions import window
 from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
@@ -15,7 +14,6 @@ from netaddr.core import AddrFormatError
 from core.models import ObjectType
 from extras.models import CachedValue, CustomField
 from netbox.registry import registry
-from utilities.object_types import object_type_identifier
 from utilities.querysets import RestrictedPrefetch
 from utilities.string import title
 
@@ -203,12 +201,6 @@ class CachedValueSearchBackend(SearchBackend):
             )
         )[:MAX_RESULTS]
 
-        # Gather all ObjectTypes present in the search results (used for prefetching related
-        # objects). This must be done before generating the final results list, which returns
-        # a RawQuerySet.
-        object_type_ids = set(queryset.values_list('object_type', flat=True))
-        object_types = ObjectType.objects.filter(pk__in=object_type_ids)
-
         # Construct a Prefetch to pre-fetch only those related objects for which the
         # user has permission to view.
         if user:
@@ -218,33 +210,13 @@ class CachedValueSearchBackend(SearchBackend):
 
         # Wrap the base query to return only the lowest-weight result for each object
         # Hat-tip to https://blog.oyam.dev/django-filter-by-window-function/ for the solution
+        # FETCH_PEERS passes to the prefetched objects, batching the relations read when rendering results
         sql, params = queryset.query.sql_with_params()
-        results = CachedValue.objects.prefetch_related(*prefetch).raw(
+        results = CachedValue.objects.fetch_mode(FETCH_PEERS).prefetch_related(*prefetch).raw(
             f"SELECT * FROM ({sql}) t WHERE row_number = 1",
             params
         )
 
-        # Iterate through each ObjectType represented in the search results and prefetch any
-        # related objects necessary to render the prescribed display attributes (display_attrs).
-        for object_type in object_types:
-            model = object_type.model_class()
-            indexer = registry['search'].get(object_type_identifier(object_type))
-            if not (display_attrs := getattr(indexer, 'display_attrs', None)):
-                continue
-
-            # Add ForeignKey fields to prefetch list
-            prefetch_fields = []
-            for attr in display_attrs:
-                field = model._meta.get_field(attr)
-                if type(field) is ForeignKey:
-                    prefetch_fields.append(f'object__{attr}')
-
-            # Compile a list of all CachedValues referencing this object type, and prefetch
-            # any related objects
-            if prefetch_fields:
-                objects = [r for r in results if r.object_type == object_type]
-                prefetch_related_objects(objects, *prefetch_fields)
-
         # Omit any results pertaining to an object the user does not have permission to view
         ret = []
         for r in results:

+ 9 - 1
netbox/netbox/settings.py

@@ -727,6 +727,7 @@ NETBOX_COPILOT_URL = 'https://static.copilot.netboxlabs.ai/load.js'
 SOCIAL_AUTH_PIPELINE = (
     'social_core.pipeline.social_auth.social_details',
     'social_core.pipeline.social_auth.social_uid',
+    'social_core.pipeline.social_auth.auth_allowed',
     'social_core.pipeline.social_auth.social_user',
     'social_core.pipeline.user.get_username',
     'social_core.pipeline.user.create_user',
@@ -748,6 +749,13 @@ for name in dir(configuration):
     if name.startswith('SOCIAL_AUTH_'):
         globals()[name] = getattr(configuration, name)
 
+# Warn if a custom pipeline omits the step which enforces allowlists
+if 'social_core.pipeline.social_auth.auth_allowed' not in SOCIAL_AUTH_PIPELINE:
+    warnings.warn(
+        "SOCIAL_AUTH_PIPELINE does not include social_core.pipeline.social_auth.auth_allowed; allowlist settings "
+        "such as WHITELISTED_EMAILS and WHITELISTED_DOMAINS will not be enforced."
+    )
+
 # Force usage of PostgreSQL's JSONB field for extra data
 SOCIAL_AUTH_JSONFIELD_ENABLED = True
 SOCIAL_AUTH_CLEAN_USERNAME_FUNCTION = 'users.utils.clean_username'
@@ -783,7 +791,7 @@ REST_FRAMEWORK = {
     ),
     'DEFAULT_FILTER_BACKENDS': (
         'django_filters.rest_framework.DjangoFilterBackend',
-        'rest_framework.filters.OrderingFilter',
+        'netbox.api.filters.OrderingFilter',
     ),
     'DEFAULT_METADATA_CLASS': 'netbox.api.metadata.BulkOperationMetadata',
     'DEFAULT_PAGINATION_CLASS': 'netbox.api.pagination.NetBoxPagination',

+ 0 - 3
netbox/netbox/tables/tables.py

@@ -44,8 +44,6 @@ class BaseTable(tables.Table):
         * User configuration (column preferences)
         * Automatic prefetching of related objects
         * BS5 styling
-
-    :param user: Personalize table display for the given user (optional). Has no effect if AnonymousUser is passed.
     """
     exempt_columns = ()
 
@@ -54,7 +52,6 @@ class BaseTable(tables.Table):
             'class': 'table table-hover object-list',
         }
 
-    # TODO: Remove user kwarg in NetBox v4.7
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
 

+ 70 - 0
netbox/netbox/tests/test_authentication.py

@@ -14,7 +14,9 @@ from django.test.utils import override_settings
 from django.urls import reverse
 from django.utils.timezone import now
 from rest_framework.test import APIClient
+from social_core.backends.base import BaseAuth
 from social_core.exceptions import AuthFailed
+from social_django.models import UserSocialAuth
 
 from account.views import LoginView
 from core.choices import ManagedFileRootPathChoices
@@ -1275,3 +1277,71 @@ class SocialAuthExceptionMiddlewareTestCase(SimpleTestCase):
         self.assertEqual(response.url, settings.LOGIN_URL)
         messages = [str(m) for m in request._messages]
         self.assertEqual(messages, [self.GENERIC_MESSAGE])
+
+
+class AllowlistTestBackend(BaseAuth):
+    """
+    Stands in for an identity provider, confirming whichever email address the request names.
+    """
+    name = 'allowlist-test'
+    ID_KEY = 'email'
+
+    def auth_complete(self, *args, **kwargs):
+        kwargs.update(response={'email': self.data['email']}, backend=self)
+        return self.strategy.authenticate(*args, **kwargs)
+
+    def get_user_details(self, response):
+        return {'username': response['email'].split('@')[0], 'email': response['email']}
+
+
+@override_settings(
+    AUTHENTICATION_BACKENDS=[
+        'netbox.tests.test_authentication.AllowlistTestBackend',
+        'netbox.authentication.ObjectPermissionBackend',
+    ],
+    SOCIAL_AUTH_ALLOWLIST_TEST_WHITELISTED_DOMAINS=['example.com'],
+)
+class SocialAuthAllowlistTestCase(DjangoTestCase):
+    """
+    Verify that the SSO login pipeline enforces a backend's allowlist (see #23584).
+    """
+
+    def setUp(self):
+        # load_backends() caches the discovered backends in a module-level dict, so isolate the
+        # backend overridden above from the remainder of the test suite.
+        cache_patcher = patch.dict('social_core.backends.utils.BACKENDSCACHE', {}, clear=True)
+        cache_patcher.start()
+        self.addCleanup(cache_patcher.stop)
+
+        self.url = reverse('social:complete', args=['allowlist-test'])
+
+    def test_email_outside_allowlist_is_refused(self):
+        response = self.client.get(self.url, {'email': 'mallory@evil.test'})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, settings.LOGIN_URL)
+        self.assertNotIn('_auth_user_id', self.client.session)
+        self.assertFalse(User.objects.filter(username='mallory').exists())
+
+    def test_existing_user_outside_allowlist_is_refused(self):
+        user = User.objects.create_user(username='mallory', email='mallory@evil.test')
+        UserSocialAuth.objects.create(user=user, provider='allowlist-test', uid='mallory@evil.test')
+
+        response = self.client.get(self.url, {'email': 'mallory@evil.test'})
+
+        self.assertEqual(response.status_code, 302)
+        self.assertEqual(response.url, settings.LOGIN_URL)
+        self.assertNotIn('_auth_user_id', self.client.session)
+
+    def test_email_inside_allowlist_is_allowed(self):
+        self.client.get(self.url, {'email': 'alice@example.com'})
+
+        self.assertIn('_auth_user_id', self.client.session)
+        self.assertTrue(User.objects.filter(username='alice').exists())
+
+    @override_settings(SOCIAL_AUTH_ALLOWLIST_TEST_WHITELISTED_DOMAINS=[])
+    def test_no_allowlist_allows_any_email(self):
+        self.client.get(self.url, {'email': 'alice@evil.test'})
+
+        self.assertIn('_auth_user_id', self.client.session)
+        self.assertTrue(User.objects.filter(username='alice').exists())

+ 67 - 1
netbox/netbox/tests/test_graphql.py

@@ -22,6 +22,7 @@ from strawberry_django.optimizer import DjangoOptimizerExtension
 from core.models import DataFile, DataSource, ObjectType
 from dcim.choices import LocationStatusChoices
 from dcim.models import (
+    Cable,
     Device,
     DeviceRole,
     DeviceType,
@@ -1066,6 +1067,71 @@ class GraphQLAPITestCase(APITestCase):
             msg=f'Expected batched MAC address lookup, got {mac_queries} MAC address queries for 5 devices',
         )
 
+    def test_graphql_deferred_fields_are_batched(self):
+        """
+        Deferred columns and relations read by a resolver the optimizer can't analyze (`link_peers` reads `cable`
+        and `wireless_link`) must load with one query for all objects returned, not one query per object.
+        """
+        self.add_permissions('dcim.view_interface')
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        devices = Device.objects.bulk_create([
+            Device(name=f'Device {index}', device_type=device_type, role=device_role, site=Site.objects.first())
+            for index in range(1, 4)
+        ])
+        interfaces = {
+            device.name: Interface.objects.bulk_create([
+                Interface(device=device, name=f'eth{index}', type='1000baset') for index in range(5)
+            ])
+            for device in devices
+        }
+        # Device 1's interfaces have no cable; device 2's are each cabled to the interface of the same name on device 3
+        for a_interface, b_interface in zip(interfaces['Device 2'], interfaces['Device 3']):
+            Cable(a_terminations=[a_interface], b_terminations=[b_interface]).save()
+
+        query = """
+        {
+            interface_list(filters: {device: {name: {exact: "%s"}}}, pagination: {limit: %d}) {
+                name
+                link_peers { ... on InterfaceType { name } }
+            }
+        }
+        """
+        url = reverse('graphql')
+
+        def get_link_peers(device_name, limit):
+            with CaptureQueriesContext(connection) as context:
+                response = self.client.post(
+                    url, data={'query': query % (device_name, limit)}, format='json', **self.header
+                )
+            self.assertHttpStatus(response, status.HTTP_200_OK)
+            data = json.loads(response.content)
+            self.assertNotIn('errors', data)
+            link_peers = {
+                interface['name']: [peer['name'] for peer in interface['link_peers']]
+                for interface in data['data']['interface_list']
+            }
+            self.assertEqual(len(link_peers), limit)
+            return link_peers, context.captured_queries
+
+        # Deferred columns (cable, wireless_link) are loaded once, however many interfaces are returned
+        interface_queries = {}
+        for limit in (1, 5):
+            link_peers, queries = get_link_peers('Device 1', limit)
+            self.assertEqual(link_peers, {f'eth{index}': [] for index in range(limit)})
+            interface_queries[limit] = count_primary_table_queries(queries, 'dcim_interface')
+        self.assertEqual(interface_queries[5], interface_queries[1])
+
+        # Cables are loaded once, however many interfaces are returned
+        cable_queries = {}
+        for limit in (1, 5):
+            link_peers, queries = get_link_peers('Device 2', limit)
+            self.assertEqual(link_peers, {f'eth{index}': [f'eth{index}'] for index in range(limit)})
+            cable_queries[limit] = count_primary_table_queries(queries, 'dcim_cable')
+        self.assertEqual(cable_queries[5], cable_queries[1])
+
     def test_offset_pagination(self):
         self.add_permissions('dcim.view_site')
         url = reverse('graphql')
@@ -1478,7 +1544,7 @@ class GraphQLDeferredColumnTestCase(APITestCase):
     A GraphQL field backed by a custom resolver is opaque to the query optimizer, which narrows column
     selection with .only() based on the fields named in the GraphQL document. Any column such a resolver
     reads must therefore be declared via an `only` hint; otherwise the column is deferred and reading it
-    reloads the row from the database once per object returned (see #22813).
+    costs an extra query (see #22813).
 
     Each test below asserts both that no single-row reload occurs and that the model's table is read
     exactly once per request, regardless of the number of objects returned.

+ 394 - 22
netbox/netbox/tests/test_jobs.py

@@ -1,20 +1,28 @@
 import uuid
+from contextlib import contextmanager
 from datetime import timedelta
 from unittest.mock import patch
 
-from django.test import RequestFactory, TestCase
+from django.core.exceptions import ValidationError
+from django.db import DatabaseError
+from django.db.models.signals import post_save
+from django.test import RequestFactory, TestCase, tag
 from django.urls import reverse
 from django.utils import timezone
+from django_pg_utils import advisory_lock
 from django_rq import get_queue
 
 from core.choices import JobStatusChoices
+from core.events import JOB_COMPLETED, JOB_FAILED
 from core.exceptions import JobFailed
 from core.models import DataSource, Job
+from core.signals import job_end
 from dcim.models import Site
 from dcim.views import SiteBulkEditView
+from extras.models import Notification
 from users.models import User
 from utilities.request import copy_safe_request
-from utilities.testing import disable_warnings
+from utilities.testing import disable_logging, disable_warnings
 from utilities.testing.mixins import RQQueueTestMixin
 
 from ..jobs import *
@@ -86,6 +94,20 @@ class JobRunnerTestCase(BaseJobRunnerTestCase):
 
         self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
 
+    def test_handle_existing_notification(self):
+        """A Job whose owner already has a notification for it completes."""
+        user = User.objects.create_user(username='job-existing-notification')
+        job = Job.objects.create(name=TestJobRunner.name, job_id=uuid.uuid4(), user=user)
+        notification = Notification.objects.create(user=user, object=job, event_type=JOB_FAILED)
+
+        TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        notification.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        self.assertEqual(job.error, '')
+        self.assertEqual(notification.event_type, JOB_COMPLETED)
+
     def test_handle_errored(self):
         class ErroredJobRunner(TestJobRunner):
             EXP = Exception('Test error')
@@ -104,6 +126,254 @@ class JobRunnerTestCase(BaseJobRunnerTestCase):
         self.assertIn('Test error', tb_message)
         self.assertNotIn(_INSTALL_ROOT, tb_message)
 
+    def test_handle_deleted_job(self):
+        """A Job deleted after its task was queued neither runs nor reschedules."""
+        job = Job.objects.create(name=TestJobRunner.name, job_id=uuid.uuid4(), interval=60)
+        stale_job = Job.objects.get(pk=job.pk)
+        job.delete()
+
+        with (
+            patch.object(TestJobRunner, 'run') as run,
+            patch.object(TestJobRunner, 'enqueue') as enqueue,
+            patch('core.models.jobs.job_start') as job_start,
+            patch('core.models.jobs.job_end') as job_end,
+            self.assertLogs('netbox.jobs', 'WARNING'),
+        ):
+            TestJobRunner.handle(stale_job)
+
+        run.assert_not_called()
+        enqueue.assert_not_called()
+        job_start.send.assert_not_called()
+        job_end.send.assert_not_called()
+        self.assertFalse(Job.objects.filter(pk=stale_job.pk).exists())
+
+    def test_handle_non_waiting_job(self):
+        """A running or terminated Job neither runs nor reschedules and keeps its row."""
+        for status in (JobStatusChoices.STATUS_RUNNING, *JobStatusChoices.TERMINAL_STATE_CHOICES):
+            with self.subTest(status=status):
+                job = Job.objects.create(name=TestJobRunner.name, job_id=uuid.uuid4(), interval=60)
+                Job.objects.filter(pk=job.pk).update(status=status)
+                original = Job.objects.filter(pk=job.pk).values().get()
+
+                with (
+                    patch.object(TestJobRunner, 'run') as run,
+                    patch.object(TestJobRunner, 'enqueue') as enqueue,
+                    patch('core.models.jobs.job_start') as job_start,
+                    patch('core.models.jobs.job_end') as job_end,
+                    self.assertLogs('netbox.jobs', 'WARNING'),
+                ):
+                    TestJobRunner.handle(job)
+
+                run.assert_not_called()
+                enqueue.assert_not_called()
+                job_start.send.assert_not_called()
+                job_end.send.assert_not_called()
+                self.assertEqual(Job.objects.filter(pk=job.pk).values().get(), original)
+
+    def test_handle_finished_job(self):
+        """A finished Job handed to handle() again neither runs nor reschedules."""
+        job = Job.objects.create(
+            name=TestJobRunner.name,
+            job_id=uuid.uuid4(),
+            interval=60,
+            status=JobStatusChoices.STATUS_COMPLETED,
+            started=timezone.now(),
+            completed=timezone.now(),
+        )
+
+        with (
+            patch.object(TestJobRunner, 'run') as run,
+            patch.object(TestJobRunner, 'enqueue') as enqueue,
+            self.assertLogs('netbox.jobs', 'WARNING'),
+        ):
+            TestJobRunner.handle(job)
+
+        run.assert_not_called()
+        enqueue.assert_not_called()
+
+    def test_handle_same_job_twice(self):
+        """A second copy of a Job's task does not run it again."""
+        job = Job.objects.create(name=TestJobRunner.name, job_id=uuid.uuid4(), interval=60)
+        stale_job = Job.objects.get(pk=job.pk)
+
+        with (
+            patch.object(TestJobRunner, 'run') as run,
+            patch.object(TestJobRunner, 'enqueue') as enqueue,
+        ):
+            TestJobRunner.handle(job)
+            with self.assertLogs('netbox.jobs', 'WARNING'):
+                TestJobRunner.handle(stale_job)
+
+        run.assert_called_once_with()
+        enqueue.assert_called_once()
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+    def test_handle_start_signal_error(self):
+        """A failing job_start receiver ends the Job errored and keeps its schedule."""
+        job = Job.objects.create(name=TestJobRunner.name, job_id=uuid.uuid4(), interval=60)
+        error = RuntimeError('Simulated receiver error')
+
+        with (
+            patch('core.models.jobs.job_start.send', side_effect=error),
+            patch.object(TestJobRunner, 'run') as run,
+            patch.object(TestJobRunner, 'enqueue') as enqueue,
+        ):
+            TestJobRunner.handle(job)
+
+        run.assert_not_called()
+        enqueue.assert_called_once()
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED)
+        self.assertEqual(job.error, repr(error))
+
+    def test_handle_claim_error(self):
+        """A Job whose claim raises ends errored and schedules no successor."""
+        job = Job.objects.create(name=TestJobRunner.name, job_id=uuid.uuid4(), interval=60)
+        error = DatabaseError('Simulated database error')
+
+        with (
+            patch.object(Job, 'start', side_effect=error),
+            patch.object(TestJobRunner, 'run') as run,
+            patch.object(TestJobRunner, 'enqueue') as enqueue,
+        ):
+            TestJobRunner.handle(job)
+
+        run.assert_not_called()
+        enqueue.assert_not_called()
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_ERRORED)
+        self.assertEqual(job.error, repr(error))
+
+    @tag('regression')  # Ref: #23535
+    def test_handle_reschedules_before_terminating(self):
+        """A periodic job's successor exists when job_end is sent."""
+        outcomes = (
+            (None, JobStatusChoices.STATUS_COMPLETED),
+            (JobFailed(), JobStatusChoices.STATUS_FAILED),
+            (RuntimeError('Simulated run error'), JobStatusChoices.STATUS_ERRORED),
+        )
+        for run_error, status in outcomes:
+            with self.subTest(status=status):
+                instance = DataSource.objects.create(name=f'Source {status}', type='local')
+                job = TestJobRunner.enqueue(instance, interval=60, schedule_at=self.get_schedule_at())
+                observed = []
+
+                def record_end(sender, **kwargs):
+                    waiting = TestJobRunner.get_jobs(instance).filter(
+                        status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
+                    )
+                    observed.append((Job.objects.get(pk=sender.pk).status, list(waiting.values_list('pk', flat=True))))
+
+                job_end.connect(record_end, sender=job)
+                try:
+                    with patch.object(TestJobRunner, 'run', side_effect=run_error), disable_warnings('netbox.jobs'):
+                        TestJobRunner.handle(job)
+                finally:
+                    job_end.disconnect(record_end, sender=job)
+
+                successor = TestJobRunner.get_jobs(instance).exclude(pk=job.pk).get()
+                self.assertEqual(observed, [(status, [successor.pk])])
+                self.assertEqual((successor.status, successor.interval), (JobStatusChoices.STATUS_SCHEDULED, 60))
+
+    def test_handle_keeps_run_outcome_when_reschedule_is_invalid(self):
+        """A reschedule rejected as invalid keeps the run's outcome and ends the job once."""
+        user = User.objects.create_user(username='job-owner')
+        outcomes = (
+            (None, JobStatusChoices.STATUS_COMPLETED),
+            (JobFailed(), JobStatusChoices.STATUS_FAILED),
+            (RuntimeError('Simulated run error'), JobStatusChoices.STATUS_ERRORED),
+        )
+        for run_error, status in outcomes:
+            with self.subTest(status=status):
+                instance = DataSource.objects.create(name=f'Source {status}', type='local')
+                job = TestJobRunner.enqueue(instance, interval=60, user=user)
+
+                with (
+                    patch.object(TestJobRunner, 'run', side_effect=run_error),
+                    patch.object(TestJobRunner, 'enqueue', side_effect=ValidationError('Simulated validation error')),
+                    patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+                    disable_logging(),
+                ):
+                    TestJobRunner.handle(job)
+
+                job.refresh_from_db()
+                self.assertEqual(job.status, status)
+                self.assertEqual(job.error, repr(run_error) if status == JobStatusChoices.STATUS_ERRORED else '')
+                self.assertEqual(job.log_entries[-1]['level'], 'error')
+                self.assertIn('Simulated validation error', job.log_entries[-1]['message'])
+                self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1)
+                send_end.assert_called_once_with(job)
+                self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
+
+    def test_handle_terminates_job_when_reschedule_fails(self):
+        """An unexpected reschedule failure still ends the job once, then propagates."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestJobRunner.enqueue(interval=60, user=user)
+
+        with (
+            patch.object(TestJobRunner, 'run'),
+            patch.object(TestJobRunner, 'enqueue', side_effect=RuntimeError('Simulated queue error')),
+            patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+            self.assertRaisesMessage(RuntimeError, 'Simulated queue error'),
+        ):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        self.assertIsNotNone(job.completed)
+        send_end.assert_called_once_with(job)
+        self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
+
+    def test_handle_terminates_job_once_when_end_receiver_fails(self):
+        """A failing job_end receiver after a successful run leaves the job completed and propagates."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestJobRunner.enqueue(user=user)
+
+        with (
+            patch.object(TestJobRunner, 'run'),
+            patch.object(job_end, 'send', side_effect=RuntimeError('Simulated receiver error')) as send_end,
+            self.assertRaisesMessage(RuntimeError, 'Simulated receiver error'),
+        ):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        send_end.assert_called_once_with(job)
+        self.assertEqual(Notification.objects.filter(user=user, object_id=job.pk).count(), 1)
+
+    def test_handle_propagates_interrupt_and_reschedules(self):
+        """An interrupted run propagates the interrupt, records no outcome and still schedules the successor."""
+        instance = DataSource.objects.create(name='Interrupted Source', type='local')
+        job = TestJobRunner.enqueue(instance, interval=60, schedule_at=self.get_schedule_at())
+
+        with patch.object(TestJobRunner, 'run', side_effect=SystemExit), self.assertRaises(SystemExit):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+        self.assertIsNone(job.completed)
+        successor = TestJobRunner.get_jobs(instance).exclude(pk=job.pk).get()
+        self.assertEqual((successor.status, successor.interval), (JobStatusChoices.STATUS_SCHEDULED, 60))
+
+    def test_handle_terminal_save_error(self):
+        """A failed terminal save propagates and sends no notification or job_end."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestJobRunner.enqueue(user=user)
+
+        with (
+            patch.object(TestJobRunner, 'run'),
+            patch.object(job, 'save', side_effect=DatabaseError('Simulated database error')),
+            patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+            self.assertRaisesMessage(DatabaseError, 'Simulated database error'),
+        ):
+            TestJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_RUNNING)
+        send_end.assert_not_called()
+        self.assertFalse(Notification.objects.filter(user=user, object_id=job.pk).exists())
+
 
 class EnqueueTestCase(BaseJobRunnerTestCase):
     """
@@ -352,9 +622,7 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
         """
         When `handle()` finishes a periodic system job, it must not create a duplicate
         scheduled job if a successor is already enqueued (issue #22232). This guards
-        against the race where a worker starts up between `job.terminate()` and the
-        finally block's reschedule, calling `enqueue_once()` which would create a parallel
-        job.
+        against a successor that another occurrence or `enqueue_once()` already created.
         """
         interval = 60
 
@@ -367,17 +635,15 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
             job_id=uuid.uuid4(),
         )
 
-        # The just-finished job. `handle()` will run its finally block.
-        finished = Job.objects.create(
+        job = Job.objects.create(
             name=TestSystemJobRunner.name,
-            status=JobStatusChoices.STATUS_COMPLETED,
             interval=interval,
-            started=timezone.now(),
-            completed=timezone.now(),
             job_id=uuid.uuid4(),
         )
 
-        TestSystemJobRunner.handle(finished)
+        TestSystemJobRunner.handle(job)
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
 
         # Only the original successor should remain enqueued — no duplicate should have
         # been created.
@@ -389,6 +655,34 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
         self.assertEqual(enqueued.count(), 1)
         self.assertEqual(enqueued.first().pk, successor.pk)
 
+    def test_handle_skips_reschedule_when_successor_is_running(self):
+        """A running occurrence of a periodic system job counts as its successor."""
+        interval = 60
+        successor = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            status=JobStatusChoices.STATUS_RUNNING,
+            interval=interval,
+            started=timezone.now(),
+            job_id=uuid.uuid4(),
+        )
+        job = Job.objects.create(
+            name=TestSystemJobRunner.name,
+            interval=interval,
+            job_id=uuid.uuid4(),
+        )
+
+        TestSystemJobRunner.handle(job)
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+
+        enqueued = Job.objects.filter(
+            name=TestSystemJobRunner.name,
+            status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES,
+            interval=interval,
+        )
+        self.assertEqual(enqueued.count(), 1)
+        self.assertEqual(enqueued.first().pk, successor.pk)
+
     def test_handle_reschedules_when_only_instance_bound_successor_exists(self):
         """
         For a system (object-less) job, an instance-bound job of the same JobRunner class
@@ -408,17 +702,16 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
             job_id=uuid.uuid4(),
         )
 
-        # Object-less finished system job.
-        finished = Job.objects.create(
+        # Object-less system job.
+        job = Job.objects.create(
             name=TestSystemJobRunner.name,
-            status=JobStatusChoices.STATUS_COMPLETED,
             interval=interval,
-            started=timezone.now(),
-            completed=timezone.now(),
             job_id=uuid.uuid4(),
         )
 
-        TestSystemJobRunner.handle(finished)
+        TestSystemJobRunner.handle(job)
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
 
         # A new object-less successor should have been scheduled.
         enqueued = Job.objects.filter(
@@ -449,18 +742,17 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
             job_id=uuid.uuid4(),
         )
 
-        finished = Job.objects.create(
+        job = Job.objects.create(
             name=TestJobRunner.name,
             object=instance,
-            status=JobStatusChoices.STATUS_COMPLETED,
             interval=interval,
-            started=timezone.now(),
-            completed=timezone.now(),
             job_id=uuid.uuid4(),
         )
 
         with patch.object(TestJobRunner, 'run'):
-            TestJobRunner.handle(finished)
+            TestJobRunner.handle(job)
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
 
         # Both the unrelated schedule and the finished job's successor should be enqueued.
         enqueued = Job.objects.filter(
@@ -470,6 +762,86 @@ class SystemJobTestCase(BaseJobRunnerTestCase):
         )
         self.assertEqual(enqueued.count(), 2)
 
+    def test_handle_keeps_one_successor_for_overlapping_jobs(self):
+        """Two occurrences of a system job ending back to back leave one successor."""
+        first = TestSystemJobRunner.enqueue(interval=60)
+        second = TestSystemJobRunner.enqueue(interval=60)
+        waiting_runs = [second]
+
+        @contextmanager
+        def end_second_after_unlock(*args, **kwargs):
+            with advisory_lock(*args, **kwargs):
+                yield
+            # Another worker ends the second occurrence as soon as the lock is free
+            while waiting_runs:
+                TestSystemJobRunner.handle(waiting_runs.pop())
+
+        with patch('netbox.jobs.advisory_lock', side_effect=end_second_after_unlock):
+            TestSystemJobRunner.handle(first)
+
+        first.refresh_from_db()
+        second.refresh_from_db()
+        self.assertEqual(first.status, JobStatusChoices.STATUS_COMPLETED)
+        self.assertEqual(second.status, JobStatusChoices.STATUS_COMPLETED)
+        waiting = TestSystemJobRunner.get_jobs().filter(status__in=JobStatusChoices.ENQUEUED_STATE_CHOICES)
+        self.assertEqual(list(waiting.values_list('status', 'interval')), [(JobStatusChoices.STATUS_SCHEDULED, 60)])
+
+    def test_handle_sends_job_end_outside_schedule_lock(self):
+        """A system job is saved as ended under the schedule lock, then notifies and sends job_end outside it."""
+        user = User.objects.create_user(username='job-owner')
+        job = TestSystemJobRunner.enqueue(interval=60, user=user)
+        held = False
+        events = []
+
+        @contextmanager
+        def track_lock(*args, **kwargs):
+            nonlocal held
+            with advisory_lock(*args, **kwargs):
+                held = True
+                try:
+                    yield
+                finally:
+                    held = False
+
+        def record_job_save(sender, instance, **kwargs):
+            if instance.pk == job.pk and instance.status in JobStatusChoices.TERMINAL_STATE_CHOICES:
+                events.append(('saved', held))
+
+        def record_notification(sender, **kwargs):
+            events.append(('notified', held))
+
+        def record_end(sender, **kwargs):
+            events.append(('ended', held))
+
+        post_save.connect(record_job_save, sender=Job)
+        post_save.connect(record_notification, sender=Notification)
+        job_end.connect(record_end, sender=job)
+        try:
+            with patch('netbox.jobs.advisory_lock', side_effect=track_lock):
+                TestSystemJobRunner.handle(job)
+        finally:
+            post_save.disconnect(record_job_save, sender=Job)
+            post_save.disconnect(record_notification, sender=Notification)
+            job_end.disconnect(record_end, sender=job)
+
+        self.assertEqual(events, [('saved', True), ('notified', False), ('ended', False)])
+
+    def test_handle_terminates_job_when_schedule_lock_fails(self):
+        """A failure to take the schedule lock still ends the job and sends job_end, then propagates."""
+        job = TestSystemJobRunner.enqueue(interval=60)
+
+        with (
+            patch('netbox.jobs.advisory_lock') as lock,
+            patch.object(job_end, 'send', wraps=job_end.send) as send_end,
+            self.assertRaisesMessage(RuntimeError, 'Simulated lock error'),
+        ):
+            lock.return_value.__enter__.side_effect = RuntimeError('Simulated lock error')
+            TestSystemJobRunner.handle(job)
+
+        job.refresh_from_db()
+        self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
+        send_end.assert_called_once_with(job)
+
 
 class AsyncViewJobTestCase(TestCase):
     """

+ 51 - 1
netbox/netbox/tests/test_search.py

@@ -9,14 +9,17 @@ from django.contrib.contenttypes.models import ContentType
 from django.db import DEFAULT_DB_ALIAS, ProgrammingError, connection, transaction
 from django.db.models.signals import post_delete, post_save
 from django.test import TestCase, TransactionTestCase
+from django.test.utils import CaptureQueriesContext
 from redis.exceptions import ConnectionError as RedisConnectionError
 
-from dcim.models import Manufacturer, Site
+from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
 from dcim.search import SiteIndex
 from extras.models import CachedValue
+from ipam.models import Prefix
 from netbox.search import deferred
 from netbox.search.backends import SearchBackend, search_backend
 from netbox.search.jobs import SearchCacheJob
+from tenancy.models import Tenant
 from utilities.testing.mixins import RQQueueTestMixin
 
 
@@ -192,6 +195,53 @@ class SearchBackendTestCase(TestCase):
         results = search_backend.search('xxxxx')
         self.assertEqual(len(results), 0)
 
+    def test_search_batches_display_attrs(self):
+        """
+        Check that a ForeignKey listed in an indexer's display_attrs is fetched for all results with one query.
+        """
+        tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
+        Site.objects.update(tenant=tenant)
+        search_backend.cache(Site.objects.all())
+
+        results = search_backend.search('site')
+        self.assertEqual(len(results), 3)
+        with self.assertNumQueries(1):
+            for result in results:
+                self.assertIn(tenant, result.display_attrs.values())
+
+    def test_search_batches_related_objects(self):
+        """
+        Check that the number of queries doesn't grow with the number of results when rendering them reads
+        related objects through __str__() or a GenericForeignKey display attribute.
+        """
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        def render_results(sites):
+            # Unnamed devices render as "<manufacturer> <model> (<pk>)"; prefixes display their scope (a site)
+            for site in sites:
+                Device.objects.create(site=site, device_type=device_type, role=role, description='Batched')
+                Prefix.objects.create(prefix=f'10.0.{site.pk}.0/24', scope=site, description='Batched')
+            search_backend.cache(Device.objects.all())
+            search_backend.cache(Prefix.objects.all())
+
+            ContentType.objects.clear_cache()
+            with CaptureQueriesContext(connection) as queries:
+                results = search_backend.search('batched')
+                rendered = [(result.name, list(result.display_attrs.values())) for result in results]
+            return rendered, len(queries)
+
+        sites = list(Site.objects.order_by('name'))
+        rendered, query_count = render_results(sites[:1])
+        self.assertEqual(len(rendered), 2)
+        rendered, more_query_count = render_results(sites[1:])
+        self.assertEqual(len(rendered), 6)
+        self.assertEqual(more_query_count, query_count)
+
+        self.assertEqual(sum(name.startswith('Manufacturer 1 Device Type 1') for name, _ in rendered), 3)
+        self.assertCountEqual([attrs[0] for name, attrs in rendered if name.startswith('10.0.')], sites)
+
 
 class DeferredCachingTestCase(RQQueueTestMixin, TestCase):
     """

+ 44 - 24
netbox/netbox/tests/test_views.py

@@ -4,12 +4,13 @@ from unittest.mock import patch
 
 from django.contrib.contenttypes.models import ContentType
 from django.core.files.uploadedfile import SimpleUploadedFile
+from django.db.models.signals import pre_delete
 from django.http import HttpResponse
 from django.test import Client, TransactionTestCase, override_settings, tag
 from django.urls import reverse
 from django.utils import timezone
 
-from core.models import DataFile, DataSource
+from core.models import DataFile, DataSource, ObjectChange
 from dcim.choices import DeviceStatusChoices, InterfaceTypeChoices, SiteStatusChoices
 from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site, VirtualChassis
 from dcim.tables import SiteTable
@@ -24,6 +25,7 @@ from netbox.constants import EMPTY_TABLE_TEXT
 from netbox.search.backends import search_backend
 from netbox.views import generic
 from users.models import User
+from utilities.exceptions import AbortRequest
 from utilities.testing import TestCase
 from utilities.views import get_action_url
 
@@ -398,12 +400,11 @@ class DeleteAtomicityMixin:
     Scenario shared by the transactional and autocommit variants of the ObjectDeleteView
     atomicity test (Ref: #22934).
 
-    VirtualChassis.delete() clears vc_position/vc_priority on each member device and saves them
-    before calling super().delete(), so an aborted deletion must roll those writes back. Otherwise
-    they commit while their queued events are discarded, leaving a committed change which
-    dispatches no events.
+    Core's pre_delete handler detaches each member device of a virtual chassis (clearing its
+    virtual_chassis) ahead of the deletion itself, so a deletion aborted by a later pre_delete
+    receiver must roll that write back. Otherwise it commits while the deletion's queued events
+    are discarded, leaving a committed change which dispatches no events.
     """
-    PROTECTION_RULES = {'dcim.virtualchassis': [CustomValidator({'name': {'eq': 'Nonexistent'}})]}
 
     def create_virtual_chassis(self):
         site = Site.objects.create(name='Site 1', slug='site-1')
@@ -421,16 +422,40 @@ class DeleteAtomicityMixin:
         return virtual_chassis, device
 
     def delete_virtual_chassis(self, virtual_chassis):
-        return self.client.post(
-            get_action_url(VirtualChassis, action='delete', kwargs={'pk': virtual_chassis.pk}),
-            data={'confirm': True}
-        )
+        """
+        Submit the deletion, aborting it from a pre_delete receiver (as a plugin's might) which fires
+        after core's handler has detached the member. A write ahead of VirtualChassis.delete() is
+        covered only by the view's atomic block, not the model's or the collector's.
+        """
+        def abort(sender, instance, **kwargs):
+            raise AbortRequest("Aborted by receiver")
+
+        original_delete = VirtualChassis.delete
+
+        def delete(instance, *args, **kwargs):
+            instance.members.update(vc_priority=None)
+            return original_delete(instance, *args, **kwargs)
+
+        pre_delete.connect(abort, sender=VirtualChassis)
+        try:
+            with patch.object(VirtualChassis, 'delete', delete):
+                return self.client.post(
+                    get_action_url(VirtualChassis, action='delete', kwargs={'pk': virtual_chassis.pk}),
+                    data={'confirm': True}
+                )
+        finally:
+            pre_delete.disconnect(abort, sender=VirtualChassis)
 
     def assertDeleteRolledBack(self, virtual_chassis, device):
         self.assertTrue(VirtualChassis.objects.filter(pk=virtual_chassis.pk).exists())
         device.refresh_from_db()
+        self.assertEqual(device.virtual_chassis_id, virtual_chassis.pk)
         self.assertEqual(device.vc_position, 1)
         self.assertEqual(device.vc_priority, 10)
+        self.assertFalse(ObjectChange.objects.filter(
+            changed_object_type=ContentType.objects.get_for_model(VirtualChassis),
+            changed_object_id=virtual_chassis.pk,
+        ).exists())
 
 
 class RolledBackWriteEventQueueTestCase(DeleteAtomicityMixin, TestCase):
@@ -459,7 +484,7 @@ class RolledBackWriteEventQueueTestCase(DeleteAtomicityMixin, TestCase):
         # created events for the objects written before the abort.
         group = VLANGroup.objects.create(name='Test Group', slug='test-group')
         VLAN.objects.create(group=group, vid=101, name='VLAN-101')
-        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan')
+        self.add_permissions('ipam.add_vlan', 'ipam.view_vlan', 'ipam.view_vlangroup')
 
         with self.assertNoEventsDispatched():
             response = self.client.post(reverse('ipam:vlan_bulk_add'), data={
@@ -549,15 +574,11 @@ class RolledBackWriteEventQueueTestCase(DeleteAtomicityMixin, TestCase):
         self.assertTrue(Device.objects.filter(pk=device.pk).exists())
         self.assertTrue(Interface.objects.filter(pk=interface.pk).exists())
 
-    @override_settings(PROTECTION_RULES=DeleteAtomicityMixin.PROTECTION_RULES)
     def test_object_delete_view_rollback_is_atomic(self):
-        # An aborted delete must not leave behind writes performed by the model's delete() before
-        # super().delete(). Note what this detects: every test here runs inside the harness'
-        # transaction, so an escaped write is rolled back with the test regardless, and the
-        # assertions below cannot see it. Without the view's atomic block, AbortRequest instead
-        # escapes the collector's atomic(savepoint=False) and marks the harness' transaction as
-        # needing rollback, so this test fails with TransactionManagementError. The committed
-        # symptom itself is covered by DeleteAtomicityTestCase below.
+        # An aborted delete must not leave behind writes performed earlier in the deletion. Without
+        # the view's atomic block, the write made ahead of VirtualChassis.delete() survives within
+        # the harness' transaction and the assertions below fail. The committed symptom itself is
+        # covered by DeleteAtomicityTestCase below.
         virtual_chassis, device = self.create_virtual_chassis()
         self.add_permissions('dcim.delete_virtualchassis', 'dcim.view_virtualchassis')
 
@@ -591,8 +612,8 @@ class DeleteAtomicityTestCase(DeleteAtomicityMixin, TransactionTestCase):
     """
     Exercise the autocommit path, which TestCase cannot reach: it wraps every test in a
     transaction, so a write which escapes the view's rollback is rolled back with the test and
-    stays invisible to the assertions. Outside a transaction, a write performed by delete() before
-    super().delete() genuinely commits unless the view opens its own atomic block. (Ref: #22934)
+    stays invisible to the assertions. Outside a transaction, a write performed earlier in the
+    deletion genuinely commits unless the deletion runs in an atomic block. (Ref: #22934)
 
     Note: TransactionTestCase teardown flushes all tables, which removes rows seeded by data
     migrations from a --keepdb database (e.g. the dcim.0206 ModuleTypeProfiles). A fresh test
@@ -605,12 +626,11 @@ class DeleteAtomicityTestCase(DeleteAtomicityMixin, TransactionTestCase):
         self.client = Client()
         self.client.force_login(self.user)
 
-    @override_settings(PROTECTION_RULES=DeleteAtomicityMixin.PROTECTION_RULES)
     def test_object_delete_view_rollback_is_atomic(self):
         virtual_chassis, device = self.create_virtual_chassis()
 
         self.delete_virtual_chassis(virtual_chassis)
 
-        # Without the view's atomic block, the member device's cleared position and priority commit
-        # here, leaving a change which dispatches no events
+        # Without the view's atomic block, the member device's cleared vc_priority commits here,
+        # leaving a change which dispatches no events
         self.assertDeleteRolledBack(virtual_chassis, device)

+ 3 - 0
netbox/netbox/views/generic/bulk_views.py

@@ -452,6 +452,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
             # Reinstantiate the model form each time to avoid overwriting the same instance. Use a mutable
             # copy of the POST QueryDict so that we can update the target field value.
             model_form = self.model_form(self.get_model_form_data(form, request, value))
+            restrict_form_fields(model_form, request.user)
 
             # Validate each new object independently.
             if model_form.is_valid():
@@ -492,6 +493,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
 
         form = self.form()
         model_form = self.model_form(initial=initial)
+        restrict_form_fields(model_form, request.user)
 
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):
@@ -506,6 +508,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
         model = self.queryset.model
         form = self.form(request.POST)
         model_form = self.model_form(request.POST)
+        restrict_form_fields(model_form, request.user)
 
         # HTMX partial: only re-render the model form fields
         if htmx_partial(request):

Некоторые файлы не были показаны из-за большого количества измененных файлов