Compare commits

...

28 Commits

Author SHA1 Message Date
Martin Hauser
268ef4f59f chore(ci): Pin CodeQL action to commit SHA
Pin GitHub/codeql-action references to full commit SHA v4.33.0 instead
of version tag to reduce supply chain risk from tag retargeting.
2026-03-16 15:14:23 +01:00
Martin Hauser
671b1cd470 chore(ci): Pin GitHub Actions to commit SHAs
Pin GitHub Actions references to full commit SHAs instead of version
tags to reduce supply chain risk from tag retargeting.

Update actions/checkout to v6.0.2, actions/setup-python to v6.2.0,
actions/setup-node to v6.3.0, actions/stale to v10.2.0, and
dessant/lock-threads to v6.0.0.
2026-03-16 14:35:51 +01:00
github-actions
21f78049bc Update source translation strings
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.13) (push) Failing after 14s
CI / build (20.x, 3.14) (push) Failing after 30s
CodeQL / Analyze (actions) (push) Failing after 1m4s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m10s
CodeQL / Analyze (python) (push) Failing after 1m9s
2026-03-14 05:18:31 +00:00
Jeremy Stretch
e28ed7446c Fixes #21578: Enable assignment of scope object by name when bulk importing prefixes/VLAN groups (#21671) 2026-03-13 16:27:26 -07:00
Jeremy Stretch
9b57512b12 Fixes #21579: Display 'add script' button only if user has sufficient permission (#21628)
CI / build (20.x, 3.12) (push) Failing after 38s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 9s
CodeQL / Analyze (actions) (push) Failing after 46s
CodeQL / Analyze (javascript-typescript) (push) Failing after 50s
CodeQL / Analyze (python) (push) Failing after 53s
* Fixes #21579: Display 'add script' button only if user has sufficient permission

* Check for core.add_managedfile permission too
2026-03-13 22:08:03 +01:00
github-actions
da79cc775d Update source translation strings
CodeQL / Analyze (actions) (push) Failing after 1m7s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m14s
CodeQL / Analyze (python) (push) Failing after 1m11s
2026-03-13 05:20:12 +00:00
Jeremy Stretch
6f5fd26183 Fixes #20077: Fix form field focus bug on Edge
CI / build (20.x, 3.12) (push) Failing after 16s
CI / build (20.x, 3.13) (push) Failing after 11s
CI / build (20.x, 3.14) (push) Failing after 12s
CodeQL / Analyze (actions) (push) Failing after 1m11s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m16s
CodeQL / Analyze (python) (push) Failing after 1m12s
2026-03-12 14:49:43 -04:00
Jason Novinger
10157394ae Fixes #21651: Disable ordering on MACAddress is_primary column
is_primary is a cached_property, not a database field, so attempting
to order by it raises a FieldError.
2026-03-12 14:48:58 -04:00
Jeremy Stretch
ae0907fb37 Fixes #20934: Fix flicker when navigating in dark mode (#21650) 2026-03-12 09:38:04 -07:00
Martin Hauser
fea6ad61fd fix(virtualization): Hide VM Add Components dropdown without change permission (#21634)
Wrap the VirtualMachine "Add Components" dropdown in a
`virtualization.change_virtualmachine` permission check to match Device
behavior and prevent users without change permission from seeing
component add actions.

Fixes #21580
2026-03-12 09:30:40 -07:00
bctiemann
675e68f276 Merge pull request #21623 from netbox-community/20923-migrate-vpn-views
CI / build (20.x, 3.12) (push) Failing after 19s
CI / build (20.x, 3.13) (push) Failing after 25s
CI / build (20.x, 3.14) (push) Failing after 39s
CodeQL / Analyze (actions) (push) Failing after 1m6s
CodeQL / Analyze (javascript-typescript) (push) Failing after 58s
CodeQL / Analyze (python) (push) Failing after 57s
#20923: Convert `vpn` views to new UI layout
2026-03-12 09:14:48 -04:00
bctiemann
20b907a8c9 Merge pull request #21630 from netbox-community/21114-data-source
#21114 Allow specifying exclude directories for Data Sources
2026-03-12 09:11:12 -04:00
Jason Novinger
8ccb0f7b63 Closes #20923: Migrate wireless app views to declarative UI layouts (#21646)
* #20923: Migrate wireless app views to declarative UI layouts

Convert WirelessLANGroup, WirelessLAN, and WirelessLink detail views
from legacy HTML templates to declarative Python layout definitions.

New files:
- wireless/ui/panels.py: Panel classes for all three model detail views
- templates/wireless/attrs/auth_psk.html: Secret toggle for PSK field
- templates/wireless/panels/wirelesslink_interface_{a,b}.html: Interface
  panels for WirelessLink detail view

Removed:
- templates/wireless/inc/authentication_attrs.html
- templates/wireless/inc/wirelesslink_interface.html

* Consolidate wireless link interface templates into ObjectPanel subclass

Replace duplicate wirelesslink_interface_{a,b}.html templates with a
single shared template and WirelessLinkInterfacePanel(ObjectPanel)
subclass that injects the correct interface via get_context().

* Rename WirelessLANAuthenticationPanel to WirelessAuthenticationPanel

Drop the 'LAN' qualifier since the panel is shared by both WirelessLAN
and WirelessLink views.

* Fix accessor shadowing in WirelessLinkInterfacePanel

Rename __init__ parameter from 'accessor' to 'interface_attr' to avoid
shadowing ObjectPanel.accessor, which would cause super().get_context()
to resolve the wrong context key.

* Use SimpleLayout for WirelessLinkView

Replace explicit Layout with SimpleLayout, which auto-includes plugin
content panels. Remove unused Row, Column, and PluginContentPanel
imports.
2026-03-12 08:55:50 -04:00
bctiemann
068fce4d7c Merge pull request #21608 from netbox-community/21440-oob-ip-import
Fixes #21440: Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
2026-03-12 08:31:40 -04:00
bctiemann
2e4bce2dad Merge pull request #21555 from ITJamie/patch-3
Add changelog message documentation in custom scripts
2026-03-12 08:29:19 -04:00
GeertJohan
dad96c525f Fixes #21618: Preserve cable terminations when bulk-editing cable profile
When `update_terminations(force=True)` is called (e.g. after a profile
change), cache the termination objects from the database before deleting
CableTermination records. Without this, the `a_terminations`/`b_terminations`
properties fall back to querying the (now-empty) DB and return empty lists,
resulting in all terminations being lost.

Also removes a leftover debug print statement.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:23:34 -04:00
Martin Hauser
cac3c1221c Closes #21631: Remove duplicate 'created' field in RackReservation table (#21632)
CI / build (20.x, 3.12) (push) Failing after 58s
CI / build (20.x, 3.13) (push) Failing after 9s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 45s
CodeQL / Analyze (javascript-typescript) (push) Failing after 48s
CodeQL / Analyze (python) (push) Failing after 50s
2026-03-11 11:49:01 -05:00
Jeremy Stretch
3a9d00a537 Update the lock-threads workflow
CI / build (20.x, 3.13) (push) Failing after 34s
CI / build (20.x, 3.12) (push) Failing after 36s
CI / build (20.x, 3.14) (push) Failing after 52s
CodeQL / Analyze (actions) (push) Failing after 1m25s
CodeQL / Analyze (javascript-typescript) (push) Failing after 1m17s
CodeQL / Analyze (python) (push) Failing after 1m16s
2026-03-11 08:56:39 -04:00
github-actions
4040e4f266 Update source translation strings 2026-03-11 05:19:17 +00:00
Jason Novinger
5e57cec369 Closes #21157: Add public models to export template context
Move shared get_context() logic from ConfigTemplate into
RenderTemplateMixin so ExportTemplate also gets access to all
public model classes. This enables export templates to perform
cross-model lookups (e.g. resolving parent Prefix from IPAddress).
2026-03-10 16:03:28 -05:00
Jeremy Stretch
f938309ed9 Second attempt to fix @claude for PRs from forks (#21633)
CI / build (20.x, 3.12) (push) Failing after 10s
CI / build (20.x, 3.13) (push) Failing after 10s
CI / build (20.x, 3.14) (push) Failing after 10s
CodeQL / Analyze (actions) (push) Failing after 48s
CodeQL / Analyze (javascript-typescript) (push) Failing after 54s
CodeQL / Analyze (python) (push) Failing after 57s
2026-03-10 10:35:28 -07:00
Arthur
86f6de40d2 add docs and tests 2026-03-10 08:58:07 -07:00
Arthur
83c6149e49 #21114 Allow specifying exclude directories for Data Sources 2026-03-10 08:46:47 -07:00
Jeremy Stretch
b19d0d61f4 Delete unused template 2026-03-09 15:48:04 -04:00
Jeremy Stretch
d64c4d75f8 #20923: Convert vpn views to new UI layout 2026-03-09 15:25:25 -04:00
Jamie (Bear) Murphy
9b0c6110bb Clarify optional changelog message in custom-scripts
Added comment to clarify optional changelog message.
2026-03-06 17:13:52 +00:00
Jeremy Stretch
c86210f024 Fixes #21440: Avoid erroneously clearing primary/OOB IP assignments during bulk import/update 2026-03-06 10:48:06 -05:00
Jamie (Bear) Murphy
1be917fb90 Add changelog message documentation in custom scripts
Add changelog message documentation in custom scripts
2026-03-03 13:10:04 +00:00
65 changed files with 1298 additions and 1416 deletions
+4 -4
View File
@@ -53,7 +53,7 @@ jobs:
steps:
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check Python linting & PEP8 compliance
uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
@@ -63,12 +63,12 @@ jobs:
src: "netbox/"
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node-version }}
@@ -76,7 +76,7 @@ jobs:
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v4
uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
with:
node-version: ${{ matrix.node-version }}
cache: yarn
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
+26 -8
View File
@@ -26,20 +26,38 @@ jobs:
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 1
# Workaround for claude-code-action bug with fork PRs: The action tries to fetch by branch name, which doesn't
# exist on origin for forks. Pre-fetch the PR ref so it's available as a local ref.
- name: Fetch fork PR ref (if applicable)
if: github.event.issue.pull_request != '' && github.event.issue.pull_request != null
# Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
# (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
# Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
- name: Configure git remote for fork PRs
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PR_NUMBER=$(gh pr view ${{ github.event.issue.number }} --json number -q .number 2>/dev/null || echo "")
if [ -n "$PR_NUMBER" ]; then
git fetch origin refs/pull/${PR_NUMBER}/head:refs/remotes/pull/${PR_NUMBER}/head || true
# Determine PR number based on event type
if [ "${{ github.event_name }}" = "issue_comment" ]; then
PR_NUMBER="${{ github.event.issue.number }}"
elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
PR_NUMBER="${{ github.event.pull_request.number }}"
else
exit 0 # issues event — no PR branch to worry about
fi
# Fetch fork info in one API call; silently skip if this is not a PR
PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
if [ -z "$PR_INFO" ]; then
exit 0
fi
IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
if [ "$IS_FORK" = "true" ]; then
FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
fi
- name: Run Claude Code
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
close-issue-message: >
This issue is being closed as no further information has been provided. If
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v9
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
# General parameters
operations-per-run: 200
+3 -3
View File
@@ -27,16 +27,16 @@ jobs:
build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Initialize CodeQL
uses: github/codeql-action/init@v4
uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
config-file: .github/codeql/codeql-config.yml
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
with:
category: "/language:${{matrix.language}}"
+4 -4
View File
@@ -11,14 +11,14 @@ permissions:
pull-requests: write
discussions: write
concurrency:
group: lock-threads
jobs:
lock:
if: github.repository == 'netbox-community/netbox'
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
- uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
with:
issue-inactive-days: 90
pr-inactive-days: 30
discussion-inactive-days: 180
issue-lock-reason: 'resolved'
+2 -2
View File
@@ -27,12 +27,12 @@ jobs:
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
- name: Check out repo
uses: actions/checkout@v4
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
- name: Set up Python
uses: actions/setup-python@v5
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: 3.12
+1
View File
@@ -215,6 +215,7 @@ if obj.pk and hasattr(obj, 'snapshot'):
obj.snapshot()
obj.property = "New Value"
obj._changelog_message = 'Example Message Text' # Optional
obj.full_clean()
obj.save()
```
+9 -6
View File
@@ -36,13 +36,16 @@ If false, synchronization will be disabled.
### Ignore Rules
A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
A set of rules (one per line) identifying files or paths to ignore during synchronization. Rules are matched against both the full relative path (e.g. `subdir/file.txt`) and the bare filename, so path-based patterns can be used to exclude entire directories. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
| Rule | Description |
|----------------|------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
| Rule | Description |
|-----------------------|------------------------------------------------------|
| `README` | Ignore any files named `README` |
| `*.txt` | Ignore any files with a `.txt` extension |
| `data???.json` | Ignore e.g. `data123.json` |
| `subdir/*` | Ignore all files within `subdir/` |
| `subdir/*/*` | Ignore all files one level deep within `subdir/` |
| `*/dev/*` | Ignore files inside any directory named `dev/` |
### Sync Interval
+1 -1
View File
@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
attrs={
'rows': 5,
'class': 'font-monospace',
'placeholder': '.cache\n*.txt'
'placeholder': '.cache\n*.txt\nsubdir/*'
}
),
}
+8 -7
View File
@@ -69,7 +69,7 @@ class DataSource(JobsMixin, PrimaryModel):
ignore_rules = models.TextField(
verbose_name=_('ignore rules'),
blank=True,
help_text=_("Patterns (one per line) matching files to ignore when syncing")
help_text=_("Patterns (one per line) matching files or paths to ignore when syncing")
)
parameters = models.JSONField(
verbose_name=_('parameters'),
@@ -258,21 +258,22 @@ class DataSource(JobsMixin, PrimaryModel):
if path.startswith('.'):
continue
for file_name in file_names:
if not self._ignore(file_name):
paths.add(os.path.join(path, file_name))
file_path = os.path.join(path, file_name)
if not self._ignore(file_path):
paths.add(file_path)
logger.debug(f"Found {len(paths)} files")
return paths
def _ignore(self, filename):
def _ignore(self, file_path):
"""
Returns a boolean indicating whether the file should be ignored per the DataSource's configured
ignore rules.
ignore rules. file_path is the full relative path (e.g. "subdir/file.txt").
"""
if filename.startswith('.'):
if os.path.basename(file_path).startswith('.'):
return True
for rule in self.ignore_rules.splitlines():
if fnmatchcase(filename, rule):
if fnmatchcase(file_path, rule) or fnmatchcase(os.path.basename(file_path), rule):
return True
return False
+20
View File
@@ -10,6 +10,26 @@ from dcim.models import Device, Location, Site
from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
class DataSourceIgnoreRulesTestCase(TestCase):
def test_no_ignore_rules(self):
ds = DataSource(ignore_rules='')
self.assertFalse(ds._ignore('README.md'))
self.assertFalse(ds._ignore('subdir/file.py'))
def test_ignore_by_filename(self):
ds = DataSource(ignore_rules='*.txt')
self.assertTrue(ds._ignore('notes.txt'))
self.assertTrue(ds._ignore('subdir/notes.txt'))
self.assertFalse(ds._ignore('notes.py'))
def test_ignore_by_subdirectory(self):
ds = DataSource(ignore_rules='dev/*')
self.assertTrue(ds._ignore('dev/README.md'))
self.assertTrue(ds._ignore('dev/script.py'))
self.assertFalse(ds._ignore('prod/script.py'))
class DataSourceChangeLoggingTestCase(TestCase):
def test_password_added_on_create(self):
+40 -1
View File
@@ -121,13 +121,52 @@ class ScopedImportForm(forms.Form):
required=False,
label=_('Scope type (app & model)')
)
scope_name = forms.CharField(
required=False,
label=_('Scope name'),
help_text=_('Name of the assigned scope object (if not using ID)')
)
def clean(self):
super().clean()
scope_id = self.cleaned_data.get('scope_id')
scope_name = self.cleaned_data.get('scope_name')
scope_type = self.cleaned_data.get('scope_type')
if scope_type and not scope_id:
# Cannot specify both scope_name and scope_id
if scope_name and scope_id:
raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
# Must specify scope_type with scope_name or scope_id
if scope_name and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_name"))
if scope_id and not scope_type:
raise ValidationError(_("scope_type must be specified when using scope_id"))
# Look up the scope object by name
if scope_type and scope_name:
model = scope_type.model_class()
try:
scope_obj = model.objects.get(name=scope_name)
except model.DoesNotExist:
raise ValidationError({
'scope_name': _('{scope_type} "{name}" not found.').format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name
)
})
except model.MultipleObjectsReturned:
raise ValidationError({
'scope_name': _(
'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
).format(
scope_type=bettertitle(model._meta.verbose_name),
name=scope_name,
)
})
self.cleaned_data['scope_id'] = scope_obj.pk
elif scope_type and not scope_id:
raise ValidationError({
'scope_id': _(
"Please select a {scope_type}."
+9 -1
View File
@@ -293,7 +293,6 @@ class Cable(PrimaryModel):
self._pk = self.pk
if self._orig_profile != self.profile:
print(f'profile changed from {self._orig_profile} to {self.profile}')
self.update_terminations(force=True)
elif self._terminations_modified:
self.update_terminations()
@@ -403,6 +402,15 @@ class Cable(PrimaryModel):
"""
a_terminations, b_terminations = self.get_terminations()
# When force-recreating terminations (e.g. after a profile change), cache the termination objects
# from the database before deleting, so they are available for recreation. Without this, the
# a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
if force:
if not hasattr(self, '_a_terminations'):
self._a_terminations = list(a_terminations.keys())
if not hasattr(self, '_b_terminations'):
self._b_terminations = list(b_terminations.keys())
# Delete any stale CableTerminations
for termination, ct in a_terminations.items():
if force or (termination.pk and termination not in self.a_terminations):
+2 -1
View File
@@ -1205,7 +1205,8 @@ class MACAddressTable(PrimaryModelTable):
verbose_name=_('Parent')
)
is_primary = columns.BooleanColumn(
verbose_name=_('Primary')
verbose_name=_('Primary'),
orderable=False,
)
tags = columns.TagColumn(
url_name='dcim:macaddress_list'
+1 -1
View File
@@ -218,7 +218,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
class Meta(PrimaryModelTable.Meta):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'tenant',
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
+29
View File
@@ -1201,6 +1201,35 @@ class CableTestCase(TestCase):
with self.assertRaises(ValidationError):
cable.clean()
def test_cable_profile_change_preserves_terminations(self):
"""
When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
bulk edit), the existing termination points must be preserved.
"""
cable = Cable.objects.first()
interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
# Verify initial state: cable has terminations and no profile
self.assertEqual(cable.profile, '')
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
# Crucially, do NOT set a_terminations or b_terminations on the instance.
cable_from_db = Cable.objects.get(pk=cable.pk)
cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
cable_from_db.save()
# Verify terminations are preserved
self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
# Verify the correct interfaces are still terminated
cable_from_db.refresh_from_db()
a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
self.assertEqual(a_terms, [interface1])
self.assertEqual(b_terms, [interface2])
class VirtualDeviceContextTestCase(TestCase):
+33 -14
View File
@@ -210,8 +210,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
class Meta:
model = Prefix
fields = (
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name',
'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
@@ -424,19 +424,36 @@ class IPAddressImportForm(PrimaryModelImportForm):
# Set as primary for device/VM
if self.cleaned_data.get('is_primary') is not None:
parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
parent.snapshot()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress if self.cleaned_data.get('is_primary') else None
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
parent.save()
if self.cleaned_data.get('is_primary'):
parent.snapshot()
if self.instance.address.version == 4:
parent.primary_ip4 = ipaddress
elif self.instance.address.version == 6:
parent.primary_ip6 = ipaddress
parent.save()
else:
# Only clear the primary IP if this IP is currently set as primary
if self.instance.address.version == 4 and parent.primary_ip4 == ipaddress:
parent.snapshot()
parent.primary_ip4 = None
parent.save()
elif self.instance.address.version == 6 and parent.primary_ip6 == ipaddress:
parent.snapshot()
parent.primary_ip6 = None
parent.save()
# Set as OOB for device
if self.cleaned_data.get('is_oob') is not None:
parent = self.cleaned_data.get('device')
parent.snapshot()
parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
parent.save()
if self.cleaned_data.get('is_oob'):
parent.snapshot()
parent.oob_ip = ipaddress
parent.save()
elif parent.oob_ip == ipaddress:
# Only clear OOB if this IP is currently set as the OOB IP
parent.snapshot()
parent.oob_ip = None
parent.save()
return ipaddress
@@ -457,7 +474,8 @@ class FHRPGroupImportForm(PrimaryModelImportForm):
fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags')
class VLANGroupImportForm(OrganizationalModelImportForm):
class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
# Override ScopedImportForm.scope_type to set custom queryset
scope_type = CSVContentTypeField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False,
@@ -477,10 +495,11 @@ class VLANGroupImportForm(OrganizationalModelImportForm):
class Meta:
model = VLANGroup
fields = (
'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags'
'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner',
'comments', 'tags',
)
labels = {
'scope_id': 'Scope ID',
'scope_id': _('Scope ID'),
}
+56 -1
View File
@@ -1,8 +1,10 @@
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
from dcim.models import Location, Region, Site, SiteGroup
from dcim.constants import InterfaceTypeChoices
from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
from ipam.forms import PrefixForm
from ipam.forms.bulk_import import IPAddressImportForm
class PrefixFormTestCase(TestCase):
@@ -41,3 +43,56 @@ class PrefixFormTestCase(TestCase):
})
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
class IPAddressImportFormTestCase(TestCase):
"""Tests for IPAddressImportForm bulk import behavior."""
@classmethod
def setUpTestData(cls):
site = Site.objects.create(name='Site 1', slug='site-1')
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
cls.device = Device.objects.create(
name='Device 1',
site=site,
device_type=device_type,
role=device_role,
)
cls.interface = Interface.objects.create(
device=cls.device,
name='eth0',
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
)
def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
"""
Regression test for #21440: importing a second IP with is_oob=False should
not clear the OOB IP set by a previous row with is_oob=True.
"""
form1 = IPAddressImportForm(data={
'address': '10.10.10.1/24',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': True,
})
self.assertTrue(form1.is_valid(), form1.errors)
ip1 = form1.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1)
form2 = IPAddressImportForm(data={
'address': '2001:db8::1/64',
'status': 'active',
'device': 'Device 1',
'interface': 'eth0',
'is_oob': False,
})
self.assertTrue(form2.is_valid(), form2.errors)
form2.save()
self.device.refresh_from_db()
self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")
+55 -13
View File
@@ -435,13 +435,21 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
site = sites[0].pk
cls.csv_data = (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
)
site = sites[0]
cls.csv_data = {
'default': (
"vrf,prefix,status,scope_type,scope_id",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}",
),
'scope_name': (
"vrf,prefix,status,scope_type,scope_name",
f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}",
f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}",
),
}
cls.csv_update_data = (
"id,description,status",
@@ -532,6 +540,32 @@ scope_id: {site.pk}
self.assertEqual(prefix.vlan.vid, 101)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_scope_name(self):
"""
Test YAML-based import using scope_name instead of scope_id.
"""
site = Site.objects.get(name='Site 1')
IMPORT_DATA = """
prefix: 10.1.3.0/24
status: active
scope_type: dcim.site
scope_name: Site 1
"""
# Add all required permissions to the test user
self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
form_data = {
'data': IMPORT_DATA,
'format': 'yaml'
}
response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
self.assertHttpStatus(response, 200)
prefix = Prefix.objects.get(prefix='10.1.3.0/24')
self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
self.assertEqual(prefix.scope, site)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
def test_prefix_import_with_vlan_group(self):
"""
@@ -884,12 +918,20 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
)
cls.csv_data = {
'default': (
"name,slug,scope_type,scope_id,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
),
'scope_name': (
"name,slug,scope_type,scope_name,description",
"VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group",
f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group",
),
}
cls.csv_update_data = (
"id,name,description",
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -6
View File
@@ -20,12 +20,7 @@ function storeColorMode(mode: ColorMode): void {
}
function updateElements(targetMode: ColorMode): void {
const body = document.querySelector('body');
if (body && targetMode == 'dark') {
body.setAttribute('data-bs-theme', 'dark');
} else if (body) {
body.setAttribute('data-bs-theme', 'light');
}
document.documentElement.setAttribute('data-bs-theme', targetMode);
for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
const svg = elevation.firstElementChild ?? null;
@@ -1,16 +1,16 @@
import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
import { addClasses } from 'tom-select/src/vanilla.ts';
import queryString from 'query-string';
import TomSelect from 'tom-select';
import type { Stringifiable } from 'query-string';
import { DynamicParamsMap } from './dynamicParamsMap';
import { NetBoxTomSelect } from './netboxTomSelect';
// Transitional
import { QueryFilter, PathFilter } from '../types';
import { getElement, replaceAll } from '../../util';
// Extends TomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends TomSelect {
// Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
export class DynamicTomSelect extends NetBoxTomSelect {
public readonly nullOption: Nullable<TomOption> = null;
// Transitional code from APISelect
@@ -0,0 +1,39 @@
import TomSelect from 'tom-select';
/**
* Extends TomSelect to work around a browser autofill bug where Edge's "last used" autofill
* simultaneously focuses multiple inputs, triggering a cascading focus/open/blur loop between
* TomSelect instances.
*
* Root cause: TomSelect's open() method calls focus(), which synchronously moves browser focus
* to this instance's control input, then schedules setTimeout(onFocus, 0). When Edge autofill
* has moved focus to a *different* select before the timeout fires, the delayed onFocus() call
* re-steals browser focus back, causing the other instance to blur and close. Each instance's
* deferred callback then repeats this, creating an infinite ping-pong loop.
*
* Fix: in the setTimeout callback, only proceed with onFocus() if this instance's element is
* still the active element. If focus has already moved elsewhere, skip the call.
*
* Upstream bug: https://github.com/orchidjs/tom-select/issues/806
* NetBox issue: https://github.com/netbox-community/netbox/issues/20077
*/
export class NetBoxTomSelect extends TomSelect {
focus(): void {
if (this.isDisabled || this.isReadOnly) return;
this.ignoreFocus = true;
const focusTarget = this.control_input.offsetWidth ? this.control_input : this.focus_node;
focusTarget.focus();
setTimeout(() => {
this.ignoreFocus = false;
// Only proceed if this instance's element is still the active element. If Edge autofill
// (or anything else) has moved focus to a different element in the interim, calling
// onFocus() here would steal focus back and restart the cascade loop.
if (document.activeElement === focusTarget || this.control.contains(document.activeElement)) {
this.onFocus();
}
}, 0);
}
}
+3 -3
View File
@@ -1,6 +1,6 @@
import { TomOption } from 'tom-select/src/types';
import TomSelect from 'tom-select';
import { escape_html } from 'tom-select/src/utils';
import { NetBoxTomSelect } from './classes/netboxTomSelect';
import { getPlugins } from './config';
import { getElements } from '../util';
@@ -9,7 +9,7 @@ export function initStaticSelects(): void {
for (const select of getElements<HTMLSelectElement>(
'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
)) {
new TomSelect(select, {
new NetBoxTomSelect(select, {
...getPlugins(select),
maxOptions: undefined,
});
@@ -25,7 +25,7 @@ export function initColorSelects(): void {
}
for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
new TomSelect(select, {
new NetBoxTomSelect(select, {
...getPlugins(select),
maxOptions: undefined,
render: {
@@ -112,7 +112,7 @@ img.plugin-icon {
}
body[data-bs-theme=dark] {
html[data-bs-theme=dark] {
// Assuming icon is black/white line art, invert it and tone down brightness
img.plugin-icon {
filter: grayscale(100%) invert(100%) brightness(80%);
@@ -93,7 +93,7 @@ pre {
}
// Dark mode overrides
body[data-bs-theme=dark] {
html[data-bs-theme=dark] {
// Override background color alpha value
::selection {
background-color: rgba(var(--tblr-primary-rgb),.48);
@@ -174,16 +174,11 @@ pre code {
}
// Theme-based visibility utilities
// Tabler's .hide-theme-* utilities expect data-bs-theme on :root, but NetBox applies
// it to body. These overrides use higher specificity selectors to ensure theme-based
// visibility works correctly. The :root:not(.dummy) pattern provides the additional
// specificity needed to override Tabler's :root:not() rules.
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-dark {
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {
display: none !important;
}
:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-dark {
:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-light,
:root:not(.dummy)[data-bs-theme='light'] .hide-theme-dark {
display: inline-flex !important;
}
@@ -77,13 +77,13 @@
}
// Light theme styling
body[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
html[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
// Background Gradient
background: linear-gradient(180deg, rgba(0, 133, 125, 0.00) 0%, rgba(0, 133, 125, 0.10) 100%), #FFF;
}
// Dark theme styling
body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
html[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
// Background Gradient
background: linear-gradient(180deg, rgba(0, 242, 212, 0.00) 0%, rgba(0, 242, 212, 0.10) 100%), #001423;
@@ -59,7 +59,7 @@ table th.orderable a {
color: var(--#{$prefix}body-color);
}
body[data-bs-theme=dark] {
html[data-bs-theme=dark] {
// Adjust table header background color
.table thead th, .markdown>table thead th {
background: $rich-black !important;
+3 -1
View File
@@ -15,7 +15,9 @@
{% endblock tabs %}
{% block controls %}
{% add_button model %}
{% if perms.core.add_managedfile and perms.extras.add_scriptmodule %}
{% add_button model %}
{% endif %}
{% endblock controls %}
{% block content %}
@@ -16,23 +16,23 @@
{% endblock %}
{% block extra_controls %}
<div class="dropdown">
{% if perms.virtualization.change_virtualmachine %}
<div class="dropdown">
<button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu" aria-labeled-by="add-components">
{% if perms.virtualization.add_vminterface %}
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
{% trans "Interfaces" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li><a class="dropdown-item" href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
{% trans "Virtual Disks" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_vminterface %}
<li><a class="dropdown-item" href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
{% trans "Interfaces" %}
</a></li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li><a class="dropdown-item" href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
{% trans "Virtual Disks" %}
</a></li>
{% endif %}
</ul>
</div>
</div>
{% endif %}
{% endblock %}
@@ -0,0 +1,3 @@
{% load i18n %}
<span id="secret" class="font-monospace" data-secret="{{ value }}">{{ value }}</span>
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
-62
View File
@@ -1,63 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IKE Version" %}</th>
<td>{{ object.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Pre-Shared Key" %}</th>
<td>
<span id="secret" class="font-monospace" data-secret="{{ object.preshared_key }}">{{ object.preshared_key|placeholder }}</span>
{% if object.preshared_key %}
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec Profiles" %}</th>
<td>
<a href="{% url 'vpn:ipsecprofile_list' %}?ike_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Proposals" %}</h2>
{% htmx_table 'vpn:ikeproposal_list' ike_policy_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-61
View File
@@ -1,62 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IKE Proposal" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Authentication method" %}</th>
<td>{{ object.get_authentication_method_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Encryption algorithm" %}</th>
<td>{{ object.get_encryption_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Authentication algorithm" %}</th>
<td>{{ object.get_authentication_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "DH group" %}</th>
<td>{{ object.get_group_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "SA lifetime (seconds)" %}</th>
<td>{{ object.sa_lifetime|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IKE Policies" %}</th>
<td>
<a href="{% url 'vpn:ikepolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ike_policies.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-50
View File
@@ -1,51 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "PFS group" %}</th>
<td>{{ object.get_pfs_group_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec Profiles" %}</th>
<td>
<a href="{% url 'vpn:ipsecprofile_list' %}?ipsec_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Proposals" %}</h2>
{% htmx_table 'vpn:ipsecproposal_list' ipsec_policy_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-101
View File
@@ -1,102 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IPSec Profile" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.get_mode_display }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ike_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ike_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.ike_policy.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.ike_policy.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ike_policy.proposals.all %}
<li>
<a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
</li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ipsec_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ipsec_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ipsec_policy.proposals.all %}
<li>
<a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
</li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<th scope="row">{% trans "PFS Group" %}</th>
<td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
</tr>
</table>
</div>
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-57
View File
@@ -1,58 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "IPSec Proposal" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Encryption algorithm" %}</th>
<td>{{ object.get_encryption_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Authentication algorithm" %}</th>
<td>{{ object.get_authentication_algorithm_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "SA lifetime (seconds)" %}</th>
<td>{{ object.sa_lifetime_seconds|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "SA lifetime (KB)" %}</th>
<td>{{ object.sa_lifetime_data|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec Policies" %}</th>
<td>
<a href="{% url 'vpn:ipsecpolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ipsec_policies.count }}</a>
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-77
View File
@@ -1,78 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "L2VPN Attributes" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Identifier" %}</th>
<td>{{ object.identifier|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>{{ object.tenant|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpn_list' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-12 col-md-6">
{% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Terminations" %}
{% if perms.vpn.add_l2vpntermination %}
<div class="card-actions">
<a href="{% url 'vpn:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'vpn:l2vpntermination_list' l2vpn_id=object.pk %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,28 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "L2VPN Attributes" %}</h2>
<table class="table table-hover">
<tr>
<th scope="row">{% trans "L2VPN" %}</th>
<td>{{ object.l2vpn|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Assigned Object" %}</th>
<td>{{ object.assigned_object|linkify }}</td>
</tr>
</table>
</div>
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpntermination_list' %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,34 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "IKE Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ike_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ike_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Version" %}</th>
<td>{{ object.ike_policy.get_version_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Mode" %}</th>
<td>{{ object.ike_policy.get_mode_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ike_policy.proposals.all %}
<li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
</table>
</div>
@@ -0,0 +1,30 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "IPSec Policy" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.ipsec_policy|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.ipsec_policy.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Proposals" %}</th>
<td>
<ul class="list-unstyled mb-0">
{% for proposal in object.ipsec_policy.proposals.all %}
<li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<th scope="row">{% trans "PFS Group" %}</th>
<td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
</tr>
</table>
</div>
-76
View File
@@ -1,6 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block extra_controls %}
@@ -10,77 +8,3 @@
</a>
{% endif %}
{% endblock %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Encapsulation" %}</th>
<td>{{ object.get_encapsulation_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "IPSec profile" %}</th>
<td>{{ object.ipsec_profile|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tunnel ID" %}</th>
<td>{{ object.tunnel_id|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Terminations" %}
{% if perms.vpn.add_tunneltermination %}
<div class="card-actions">
<a href="{% url 'vpn:tunneltermination_add' %}?tunnel={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'vpn:tunneltermination_list' tunnel_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
-40
View File
@@ -1,13 +1,6 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'vpn:tunnelgroup_list' %}">{% trans "Tunnel Groups" %}</a></li>
{% endblock %}
{% block extra_controls %}
{% if perms.vpn.add_tunnel %}
<a href="{% url 'vpn:tunnel_add' %}?group={{ object.pk }}" class="btn btn-primary">
@@ -15,36 +8,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/comments.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,57 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Tunnel Termination" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Tunnel" %}</th>
<td>{{ object.tunnel|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
</tr>
<tr>
<th scope="row">
{% if object.termination.device %}
{% trans "Device" %}
{% elif object.termination.virtual_machine %}
{% trans "Virtual Machine" %}
{% endif %}
</th>
<td>{{ object.termination.parent_object|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ object.termination|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Outside IP" %}</th>
<td>{{ object.outside_ip|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Peer Terminations" %}</h2>
{% htmx_table 'vpn:tunneltermination_list' tunnel_id=object.tunnel.pk id__n=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -0,0 +1,3 @@
{% load i18n %}
<span id="secret" class="font-monospace" data-secret="{{ value }}">{{ value }}</span>
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
@@ -1,25 +0,0 @@
{% load helpers %}
{% load i18n %}
<div class="card">
<h2 class="card-header">{% trans "Authentication" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ object.get_auth_type_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Cipher" %}</th>
<td>{{ object.get_auth_cipher_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "PSK" %}</th>
<td>
<span id="secret" class="font-monospace" data-secret="{{ object.auth_psk }}">{{ object.auth_psk|placeholder }}</span>
{% if object.auth_psk %}
<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
{% endif %}
</td>
</tr>
</table>
</div>
@@ -1,51 +0,0 @@
{% load helpers %}
{% load i18n %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ interface.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ interface|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>
{{ interface.get_type_display }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>
{{ interface.get_rf_role_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel" %}</th>
<td>
{{ interface.get_rf_channel_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Frequency" %}</th>
<td>
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|floatformat:"-2" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Width" %}</th>
<td>
{% if interface.rf_channel_width %}
{{ interface.rf_channel_width|floatformat:"-3" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
@@ -0,0 +1,48 @@
{% extends "ui/panels/_base.html" %}
{% load helpers %}
{% load i18n %}
{% block panel_content %}
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Device" %}</th>
<td>{{ interface.device|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Interface" %}</th>
<td>{{ interface|linkify }}</td>
</tr>
<tr>
<th scope="row">{% trans "Type" %}</th>
<td>{{ interface.get_type_display }}</td>
</tr>
<tr>
<th scope="row">{% trans "Role" %}</th>
<td>{{ interface.get_rf_role_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Channel" %}</th>
<td>{{ interface.get_rf_channel_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Frequency" %}</th>
<td>
{% if interface.rf_channel_frequency %}
{{ interface.rf_channel_frequency|floatformat:"-2" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "Channel Width" %}</th>
<td>
{% if interface.rf_channel_width %}
{{ interface.rf_channel_width|floatformat:"-3" }} {% trans "MHz" context "Abbreviation for megahertz" %}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
{% endblock panel_content %}
@@ -1,74 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Wireless LAN" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "SSID" %}</th>
<td>{{ object.ssid }}</td>
</tr>
<tr>
<th scope="row">{% trans "Group" %}</th>
<td>{{ object.group|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "Scope" %}</th>
{% if object.scope %}
<td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "VLAN" %}</th>
<td>{{ object.vlan|linkify|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'wireless/inc/authentication_attrs.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">{% trans "Attached Interfaces" %}</h2>
<div class="card-body table-responsive">
{% render_table interfaces_table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
</div>
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,7 +1,4 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% block breadcrumbs %}
@@ -18,53 +15,3 @@
</a>
{% endif %}
{% endblock extra_controls %}
{% block content %}
<div class="row mb-3">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Wireless LAN Group" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ object.name }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Parent" %}</th>
<td>{{ object.parent|linkify|placeholder }}</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
{% include 'inc/panels/related_objects.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h2 class="card-header">
{% trans "Child Groups" %}
{% if perms.wireless.add_wirelesslangroup %}
<div class="card-actions">
<a href="{% url 'wireless:wirelesslangroup_add' %}?parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Wireless LAN Group" %}
</a>
</div>
{% endif %}
</h2>
{% htmx_table 'wireless:wirelesslangroup_list' parent_id=object.pk %}
</div>
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
@@ -1,68 +1 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% load i18n %}
{% block content %}
<div class="row">
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %} A</h2>
{% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %}
</div>
<div class="card">
<h2 class="card-header">{% trans "Link Properties" %}</h2>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Status" %}</th>
<td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
</tr>
<tr>
<th scope="row">{% trans "SSID" %}</th>
<td>{{ object.ssid|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Tenant" %}</th>
<td>
{% if object.tenant.group %}
{{ object.tenant.group|linkify }} /
{% endif %}
{{ object.tenant|linkify|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Distance" %}</th>
<td>
{% if object.distance is not None %}
{{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div>
{% include 'inc/panels/tags.html' %}
{% include 'inc/panels/comments.html' %}
{% plugin_left_page object %}
</div>
<div class="col col-12 col-md-6">
<div class="card">
<h2 class="card-header">{% trans "Interface" %} B</h2>
{% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
</div>
{% include 'wireless/inc/authentication_attrs.html' %}
{% include 'inc/panels/custom_fields.html' %}
{% plugin_right_page object %}
</div>
</div>
<div class="row">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div>
</div>
{% endblock %}
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -74,8 +74,8 @@ class ClusterImportForm(ScopedImportForm, PrimaryModelImportForm):
class Meta:
model = Cluster
fields = (
'name', 'type', 'group', 'status', 'scope_type', 'scope_id', 'tenant', 'description', 'owner', 'comments',
'tags',
'name', 'type', 'group', 'status', 'scope_type', 'scope_name', 'scope_id', 'tenant', 'description', 'owner',
'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
+14 -6
View File
@@ -157,12 +157,20 @@ class ClusterTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"name,type,status",
"Cluster 4,Cluster Type 1,active",
"Cluster 5,Cluster Type 1,active",
"Cluster 6,Cluster Type 1,active",
)
cls.csv_data = {
'default': (
"name,type,status,scope_type,scope_id",
f"Cluster 4,Cluster Type 1,active,dcim.site,{sites[0].pk}",
f"Cluster 5,Cluster Type 1,active,dcim.site,{sites[0].pk}",
f"Cluster 6,Cluster Type 1,active,dcim.site,{sites[0].pk}",
),
'scope_name': (
"name,type,status,scope_type,scope_name",
f"Cluster 4,Cluster Type 1,active,dcim.site,{sites[0].name}",
f"Cluster 5,Cluster Type 1,active,dcim.site,{sites[0].name}",
f"Cluster 6,Cluster Type 1,active,dcim.site,{sites[0].name}",
),
}
cls.csv_update_data = (
"id,name,comments",
View File
+85
View File
@@ -0,0 +1,85 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class TunnelGroupPanel(panels.OrganizationalObjectPanel):
pass
class TunnelPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
status = attrs.ChoiceAttr('status')
group = attrs.RelatedObjectAttr('group', linkify=True)
description = attrs.TextAttr('description')
encapsulation = attrs.ChoiceAttr('encapsulation')
ipsec_profile = attrs.RelatedObjectAttr('ipsec_profile', linkify=True, label=_('IPSec profile'))
tunnel_id = attrs.TextAttr('tunnel_id', label=_('Tunnel ID'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class TunnelTerminationPanel(panels.ObjectAttributesPanel):
tunnel = attrs.RelatedObjectAttr('tunnel', linkify=True)
role = attrs.ChoiceAttr('role')
parent_object = attrs.RelatedObjectAttr(
'termination.parent_object', linkify=True, label=_('Parent')
)
termination = attrs.RelatedObjectAttr('termination', linkify=True, label=_('Interface'))
outside_ip = attrs.RelatedObjectAttr('outside_ip', linkify=True, label=_('Outside IP'))
class IKEProposalPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
authentication_method = attrs.ChoiceAttr('authentication_method', label=_('Authentication method'))
encryption_algorithm = attrs.ChoiceAttr('encryption_algorithm', label=_('Encryption algorithm'))
authentication_algorithm = attrs.ChoiceAttr('authentication_algorithm', label=_('Authentication algorithm'))
group = attrs.ChoiceAttr('group', label=_('DH group'))
sa_lifetime = attrs.TextAttr('sa_lifetime', label=_('SA lifetime (seconds)'))
class IKEPolicyPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
version = attrs.ChoiceAttr('version', label=_('IKE version'))
mode = attrs.ChoiceAttr('mode')
preshared_key = attrs.TemplatedAttr(
'preshared_key',
label=_('Pre-shared key'),
template_name='vpn/attrs/preshared_key.html',
)
class IPSecProposalPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
encryption_algorithm = attrs.ChoiceAttr('encryption_algorithm', label=_('Encryption algorithm'))
authentication_algorithm = attrs.ChoiceAttr('authentication_algorithm', label=_('Authentication algorithm'))
sa_lifetime_seconds = attrs.TextAttr('sa_lifetime_seconds', label=_('SA lifetime (seconds)'))
sa_lifetime_data = attrs.TextAttr('sa_lifetime_data', label=_('SA lifetime (KB)'))
class IPSecPolicyPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
pfs_group = attrs.ChoiceAttr('pfs_group', label=_('PFS group'))
class IPSecProfilePanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
description = attrs.TextAttr('description')
mode = attrs.ChoiceAttr('mode')
class L2VPNPanel(panels.ObjectAttributesPanel):
name = attrs.TextAttr('name')
identifier = attrs.TextAttr('identifier')
type = attrs.ChoiceAttr('type')
status = attrs.ChoiceAttr('status')
description = attrs.TextAttr('description')
tenant = attrs.RelatedObjectAttr('tenant', linkify=True)
class L2VPNTerminationPanel(panels.ObjectAttributesPanel):
l2vpn = attrs.RelatedObjectAttr('l2vpn', linkify=True, label=_('L2VPN'))
assigned_object = attrs.RelatedObjectAttr('assigned_object', linkify=True, label=_('Assigned object'))
+209 -2
View File
@@ -1,11 +1,24 @@
from django.utils.translation import gettext_lazy as _
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from ipam.tables import RouteTargetTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ContextTablePanel,
ObjectsTablePanel,
PluginContentPanel,
RelatedObjectsPanel,
TemplatePanel,
)
from netbox.views import generic
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
from .ui import panels
#
# Tunnel groups
@@ -25,6 +38,17 @@ class TunnelGroupListView(generic.ObjectListView):
@register_model_view(TunnelGroup)
class TunnelGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = TunnelGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TunnelGroupPanel(),
TagsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CommentsPanel(),
CustomFieldsPanel(),
],
)
def get_extra_context(self, request, instance):
return {
@@ -92,6 +116,30 @@ class TunnelListView(generic.ObjectListView):
@register_model_view(Tunnel)
class TunnelView(generic.ObjectView):
queryset = Tunnel.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TunnelPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
CommentsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.tunneltermination',
filters={'tunnel_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'vpn.tunneltermination',
url_params={'tunnel': lambda ctx: ctx['object'].pk},
label=_('Add a Termination'),
),
],
title=_('Terminations'),
),
],
)
@register_model_view(Tunnel, 'add', detail=False)
@@ -160,6 +208,25 @@ class TunnelTerminationListView(generic.ObjectListView):
@register_model_view(TunnelTermination)
class TunnelTerminationView(generic.ObjectView):
queryset = TunnelTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.TunnelTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.tunneltermination',
filters={
'tunnel_id': lambda ctx: ctx['object'].tunnel.pk,
'id__n': lambda ctx: ctx['object'].pk,
},
title=_('Peer Terminations'),
),
],
)
@register_model_view(TunnelTermination, 'add', detail=False)
@@ -210,6 +277,23 @@ class IKEProposalListView(generic.ObjectListView):
@register_model_view(IKEProposal)
class IKEProposalView(generic.ObjectView):
queryset = IKEProposal.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IKEProposalPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ikepolicy',
filters={'ike_proposal_id': lambda ctx: ctx['object'].pk},
title=_('IKE Policies'),
),
],
)
@register_model_view(IKEProposal, 'add', detail=False)
@@ -264,8 +348,31 @@ class IKEPolicyListView(generic.ObjectListView):
@register_model_view(IKEPolicy)
class IKEPolicyView(generic.ObjectView):
class IKEPolicyView(GetRelatedModelsMixin, generic.ObjectView):
queryset = IKEPolicy.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IKEPolicyPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
RelatedObjectsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ikeproposal',
filters={'ike_policy_id': lambda ctx: ctx['object'].pk},
title=_('Proposals'),
),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(IKEPolicy, 'add', detail=False)
@@ -322,6 +429,23 @@ class IPSecProposalListView(generic.ObjectListView):
@register_model_view(IPSecProposal)
class IPSecProposalView(generic.ObjectView):
queryset = IPSecProposal.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPSecProposalPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ipsecpolicy',
filters={'ipsec_proposal_id': lambda ctx: ctx['object'].pk},
title=_('IPSec Policies'),
),
],
)
@register_model_view(IPSecProposal, 'add', detail=False)
@@ -376,8 +500,31 @@ class IPSecPolicyListView(generic.ObjectListView):
@register_model_view(IPSecPolicy)
class IPSecPolicyView(generic.ObjectView):
class IPSecPolicyView(GetRelatedModelsMixin, generic.ObjectView):
queryset = IPSecPolicy.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPSecPolicyPanel(),
],
right_panels=[
CustomFieldsPanel(),
CommentsPanel(),
TagsPanel(),
RelatedObjectsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
'vpn.ipsecproposal',
filters={'ipsec_policy_id': lambda ctx: ctx['object'].pk},
title=_('Proposals'),
),
],
)
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(IPSecPolicy, 'add', detail=False)
@@ -434,6 +581,18 @@ class IPSecProfileListView(generic.ObjectListView):
@register_model_view(IPSecProfile)
class IPSecProfileView(generic.ObjectView):
queryset = IPSecProfile.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.IPSecProfilePanel(),
TagsPanel(),
CustomFieldsPanel(),
CommentsPanel(),
],
right_panels=[
TemplatePanel('vpn/panels/ipsecprofile_ike_policy.html'),
TemplatePanel('vpn/panels/ipsecprofile_ipsec_policy.html'),
],
)
@register_model_view(IPSecProfile, 'add', detail=False)
@@ -490,6 +649,45 @@ class L2VPNListView(generic.ObjectListView):
@register_model_view(L2VPN)
class L2VPNView(generic.ObjectView):
queryset = L2VPN.objects.all()
layout = layout.Layout(
layout.Row(
layout.Column(
panels.L2VPNPanel(),
TagsPanel(),
PluginContentPanel('left_page'),
),
layout.Column(
CustomFieldsPanel(),
CommentsPanel(),
PluginContentPanel('right_page'),
),
),
layout.Row(
layout.Column(
ContextTablePanel('import_targets_table', title=_('Import Route Targets')),
),
layout.Column(
ContextTablePanel('export_targets_table', title=_('Export Route Targets')),
),
),
layout.Row(
layout.Column(
ObjectsTablePanel(
'vpn.l2vpntermination',
filters={'l2vpn_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'vpn.l2vpntermination',
url_params={'l2vpn': lambda ctx: ctx['object'].pk},
label=_('Add a Termination'),
),
],
title=_('Terminations'),
),
PluginContentPanel('full_width_page'),
),
),
)
def get_extra_context(self, request, instance):
import_targets_table = RouteTargetTable(
@@ -564,6 +762,15 @@ class L2VPNTerminationListView(generic.ObjectListView):
@register_model_view(L2VPNTermination)
class L2VPNTerminationView(generic.ObjectView):
queryset = L2VPNTermination.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.L2VPNTerminationPanel(),
],
right_panels=[
CustomFieldsPanel(),
TagsPanel(),
],
)
@register_model_view(L2VPNTermination, 'add', detail=False)
+1 -1
View File
@@ -76,7 +76,7 @@ class WirelessLANImportForm(ScopedImportForm, PrimaryModelImportForm):
model = WirelessLAN
fields = (
'ssid', 'group', 'status', 'vlan', 'tenant', 'auth_type', 'auth_cipher', 'auth_psk', 'scope_type',
'scope_id', 'description', 'owner', 'comments', 'tags',
'scope_name', 'scope_id', 'description', 'owner', 'comments', 'tags',
)
labels = {
'scope_id': _('Scope ID'),
+34 -15
View File
@@ -116,23 +116,42 @@ class WirelessLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"group,ssid,status,tenant,scope_type,scope_id",
"Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
status=WirelessLANStatusChoices.STATUS_ACTIVE,
tenant=tenants[0].name
cls.csv_data = {
'default': (
"group,ssid,status,tenant,scope_type,scope_id",
"Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
status=WirelessLANStatusChoices.STATUS_ACTIVE,
tenant=tenants[0].name
),
"Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_DISABLED,
tenant=tenants[1].name,
site=sites[0].pk
),
"Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_RESERVED,
tenant=tenants[2].name,
site=sites[1].pk
),
),
"Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_DISABLED,
tenant=tenants[1].name,
site=sites[0].pk
'scope_name': (
"group,ssid,status,tenant,scope_type,scope_name",
"Wireless LAN Group 2,WLAN4,{status},{tenant},,".format(
status=WirelessLANStatusChoices.STATUS_ACTIVE,
tenant=tenants[0].name
),
"Wireless LAN Group 2,WLAN5,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_DISABLED,
tenant=tenants[1].name,
site=sites[0].name
),
"Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_RESERVED,
tenant=tenants[2].name,
site=sites[1].name
),
),
"Wireless LAN Group 2,WLAN6,{status},{tenant},dcim.site,{site}".format(
status=WirelessLANStatusChoices.STATUS_RESERVED,
tenant=tenants[2].name,
site=sites[1].pk
),
)
}
cls.csv_update_data = (
"id,ssid",
View File
+51
View File
@@ -0,0 +1,51 @@
from django.utils.translation import gettext_lazy as _
from netbox.ui import attrs, panels
class WirelessLANGroupPanel(panels.NestedGroupObjectPanel):
pass
class WirelessLANPanel(panels.ObjectAttributesPanel):
ssid = attrs.TextAttr('ssid', label=_('SSID'))
group = attrs.RelatedObjectAttr('group', linkify=True)
status = attrs.ChoiceAttr('status')
scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
description = attrs.TextAttr('description')
vlan = attrs.RelatedObjectAttr('vlan', label=_('VLAN'), linkify=True)
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
class WirelessAuthenticationPanel(panels.ObjectAttributesPanel):
title = _('Authentication')
auth_type = attrs.ChoiceAttr('auth_type', label=_('Type'))
auth_cipher = attrs.ChoiceAttr('auth_cipher', label=_('Cipher'))
auth_psk = attrs.TemplatedAttr('auth_psk', label=_('PSK'), template_name='wireless/attrs/auth_psk.html')
class WirelessLinkInterfacePanel(panels.ObjectPanel):
template_name = 'wireless/panels/wirelesslink_interface.html'
def __init__(self, interface_attr, title, **kwargs):
super().__init__(**kwargs)
self.interface_attr = interface_attr
self.title = title
def get_context(self, context):
obj = context['object']
return {
**super().get_context(context),
'interface': getattr(obj, self.interface_attr),
}
class WirelessLinkPropertiesPanel(panels.ObjectAttributesPanel):
title = _('Link Properties')
status = attrs.ChoiceAttr('status')
ssid = attrs.TextAttr('ssid', label=_('SSID'))
tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
description = attrs.TextAttr('description')
distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+68 -11
View File
@@ -1,10 +1,20 @@
from django.utils.translation import gettext_lazy as _
from dcim.models import Interface
from extras.ui.panels import CustomFieldsPanel, TagsPanel
from netbox.ui import actions, layout
from netbox.ui.panels import (
CommentsPanel,
ObjectsTablePanel,
RelatedObjectsPanel,
)
from netbox.views import generic
from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
from .ui import panels
#
# Wireless LAN groups
@@ -28,6 +38,33 @@ class WirelessLANGroupListView(generic.ObjectListView):
@register_model_view(WirelessLANGroup)
class WirelessLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = WirelessLANGroup.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.WirelessLANGroupPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
RelatedObjectsPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='wireless.WirelessLANGroup',
title=_('Child Groups'),
filters={'parent_id': lambda ctx: ctx['object'].pk},
actions=[
actions.AddObject(
'wireless.WirelessLANGroup',
label=_('Add Wireless LAN Group'),
url_params={
'parent': lambda ctx: ctx['object'].pk,
}
),
],
),
],
)
def get_extra_context(self, request, instance):
groups = instance.get_descendants(include_self=True)
@@ -105,17 +142,24 @@ class WirelessLANListView(generic.ObjectListView):
@register_model_view(WirelessLAN)
class WirelessLANView(generic.ObjectView):
queryset = WirelessLAN.objects.all()
def get_extra_context(self, request, instance):
attached_interfaces = Interface.objects.restrict(request.user, 'view').filter(
wireless_lans=instance
)
interfaces_table = tables.WirelessLANInterfacesTable(attached_interfaces)
interfaces_table.configure(request)
return {
'interfaces_table': interfaces_table,
}
layout = layout.SimpleLayout(
left_panels=[
panels.WirelessLANPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.WirelessAuthenticationPanel(),
CustomFieldsPanel(),
],
bottom_panels=[
ObjectsTablePanel(
model='dcim.Interface',
title=_('Attached Interfaces'),
filters={'wireless_lan_id': lambda ctx: ctx['object'].pk},
),
],
)
@register_model_view(WirelessLAN, 'add', detail=False)
@@ -173,6 +217,19 @@ class WirelessLinkListView(generic.ObjectListView):
@register_model_view(WirelessLink)
class WirelessLinkView(generic.ObjectView):
queryset = WirelessLink.objects.all()
layout = layout.SimpleLayout(
left_panels=[
panels.WirelessLinkInterfacePanel('interface_a', title=_('Interface A')),
panels.WirelessLinkPropertiesPanel(),
TagsPanel(),
CommentsPanel(),
],
right_panels=[
panels.WirelessLinkInterfacePanel('interface_b', title=_('Interface B')),
panels.WirelessAuthenticationPanel(),
CustomFieldsPanel(),
],
)
@register_model_view(WirelessLink, 'add', detail=False)