mirror of
https://github.com/netbox-community/netbox.git
synced 2026-03-12 05:22:17 +01:00
Compare commits
1 Commits
21556-fix-
...
21440-oob-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c86210f024 |
32
.github/workflows/claude.yml
vendored
32
.github/workflows/claude.yml
vendored
@@ -30,39 +30,9 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
# 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: |
|
||||
# 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
|
||||
id: claude
|
||||
uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
|
||||
uses: anthropics/claude-code-action@v1
|
||||
with:
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
|
||||
|
||||
8
.github/workflows/lock-threads.yml
vendored
8
.github/workflows/lock-threads.yml
vendored
@@ -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@v6.0.0
|
||||
- uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
|
||||
with:
|
||||
issue-inactive-days: 90
|
||||
pr-inactive-days: 30
|
||||
discussion-inactive-days: 180
|
||||
issue-lock-reason: 'resolved'
|
||||
|
||||
@@ -84,8 +84,6 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
|
||||
|
||||
* It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
|
||||
|
||||
* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
|
||||
|
||||
* New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
|
||||
|
||||
* In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
|
||||
@@ -98,10 +96,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
|
||||
greater than 80 characters in length
|
||||
|
||||
> [!CAUTION]
|
||||
> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human.
|
||||
> Any contributions which include AI-generated or reproduced content will be rejected.
|
||||
|
||||
* 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.)
|
||||
* If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
|
||||
* Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
|
||||
* All new functionality must include relevant tests where applicable.
|
||||
|
||||
|
||||
@@ -23,9 +23,9 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
|
||||
|
||||
The following data is available as context for Jinja2 templates:
|
||||
|
||||
* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
|
||||
* `event` - The type of event which triggered the webhook: created, updated, or deleted.
|
||||
* `model` - The NetBox model which triggered the change.
|
||||
* `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
|
||||
* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
|
||||
* `username` - The name of the user account associated with the change.
|
||||
* `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
|
||||
* `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
|
||||
@@ -38,20 +38,18 @@ If no body template is specified, the request body will be populated with a JSON
|
||||
```json
|
||||
{
|
||||
"event": "created",
|
||||
"timestamp": "2026-03-06T15:11:23.503186+00:00",
|
||||
"object_type": "dcim.site",
|
||||
"timestamp": "2021-03-09 17:55:33.968016+00:00",
|
||||
"model": "site",
|
||||
"username": "jstretch",
|
||||
"request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
|
||||
"request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
|
||||
"data": {
|
||||
"id": 4,
|
||||
"url": "/api/dcim/sites/4/",
|
||||
"display_url": "/dcim/sites/4/",
|
||||
"display": "Site 1",
|
||||
"id": 19,
|
||||
"name": "Site 1",
|
||||
"slug": "site-1",
|
||||
"status": {
|
||||
"status":
|
||||
"value": "active",
|
||||
"label": "Active"
|
||||
"label": "Active",
|
||||
"id": 1
|
||||
},
|
||||
"region": null,
|
||||
...
|
||||
@@ -59,10 +57,8 @@ If no body template is specified, the request body will be populated with a JSON
|
||||
"snapshots": {
|
||||
"prechange": null,
|
||||
"postchange": {
|
||||
"created": "2026-03-06T15:11:23.484Z",
|
||||
"owner": null,
|
||||
"description": "",
|
||||
"comments": "",
|
||||
"created": "2021-03-09",
|
||||
"last_updated": "2021-03-09T17:55:33.851Z",
|
||||
"name": "Site 1",
|
||||
"slug": "site-1",
|
||||
"status": "active",
|
||||
|
||||
@@ -77,14 +77,14 @@ The file path to a particular certificate authority (CA) file to use when valida
|
||||
|
||||
## Context Data
|
||||
|
||||
The following context variables are available to the text and link templates.
|
||||
The following context variables are available in to the text and link templates.
|
||||
|
||||
| Variable | Description |
|
||||
|---------------|------------------------------------------------------|
|
||||
| `event` | The event type (`created`, `updated`, or `deleted`) |
|
||||
| `timestamp` | The time at which the event occurred |
|
||||
| `object_type` | The type of object impacted (`app_label.model_name`) |
|
||||
| `username` | The name of the user associated with the change |
|
||||
| `request_id` | The unique request ID |
|
||||
| `data` | A complete serialized representation of the object |
|
||||
| `snapshots` | Pre- and post-change snapshots of the object |
|
||||
| Variable | Description |
|
||||
|--------------|----------------------------------------------------|
|
||||
| `event` | The event type (`create`, `update`, or `delete`) |
|
||||
| `timestamp` | The time at which the event occured |
|
||||
| `model` | The type of object impacted |
|
||||
| `username` | The name of the user associated with the change |
|
||||
| `request_id` | The unique request ID |
|
||||
| `data` | A complete serialized representation of the object |
|
||||
| `snapshots` | Pre- and post-change snapshots of the object |
|
||||
|
||||
@@ -405,7 +405,6 @@ class DeviceViewSet(
|
||||
NetBoxModelViewSet
|
||||
):
|
||||
queryset = Device.objects.prefetch_related(
|
||||
'device_type__manufacturer', # Referenced by Device.__str__() for unnamed devices
|
||||
'parent_bay', # Referenced by DeviceSerializer.get_parent_device()
|
||||
)
|
||||
filterset_class = filtersets.DeviceFilterSet
|
||||
|
||||
@@ -306,9 +306,12 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
|
||||
fields = ('id', 'name', 'slug', 'facility', 'description')
|
||||
|
||||
def search(self, queryset, name, value):
|
||||
# Extend `search()` to include querying on Location.facility
|
||||
# extended in order to include querying on Location.facility
|
||||
queryset = super().search(queryset, name, value)
|
||||
|
||||
if value.strip():
|
||||
return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
|
||||
queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
|
||||
|
||||
return queryset
|
||||
|
||||
|
||||
|
||||
@@ -1529,11 +1529,8 @@ class CableImportForm(PrimaryModelImportForm):
|
||||
|
||||
model = content_type.model_class()
|
||||
try:
|
||||
if (
|
||||
device.virtual_chassis and
|
||||
device.virtual_chassis.master == device and
|
||||
not model.objects.filter(device=device, name=name).exists()
|
||||
):
|
||||
if device.virtual_chassis and device.virtual_chassis.master == device and \
|
||||
model.objects.filter(device=device, name=name).count() == 0:
|
||||
termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
|
||||
else:
|
||||
termination_object = model.objects.get(device=device, name=name)
|
||||
|
||||
@@ -267,32 +267,32 @@ class DeviceFilter(
|
||||
longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_ports')
|
||||
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_server_ports')
|
||||
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlets')
|
||||
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_ports')
|
||||
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_ports')
|
||||
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_ports')
|
||||
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bays')
|
||||
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bays')
|
||||
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
@@ -383,36 +383,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
|
||||
rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_port_templates')
|
||||
)
|
||||
consoleserverporttemplates: (
|
||||
console_port_templates: (
|
||||
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
console_server_port_templates: (
|
||||
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field(name='console_server_port_templates')
|
||||
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_port_templates')
|
||||
)
|
||||
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlet_templates')
|
||||
)
|
||||
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='interface_templates')
|
||||
)
|
||||
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_port_templates')
|
||||
)
|
||||
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_port_templates')
|
||||
)
|
||||
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bay_templates')
|
||||
)
|
||||
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bay_templates')
|
||||
)
|
||||
inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='inventory_item_templates')
|
||||
)
|
||||
) = strawberry_django.filter_field()
|
||||
power_port_templates: (
|
||||
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
power_outlet_templates: (
|
||||
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
interface_templates: (
|
||||
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
front_port_templates: (
|
||||
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
rear_port_templates: (
|
||||
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
device_bay_templates: (
|
||||
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
module_bay_templates: (
|
||||
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
inventory_item_templates: (
|
||||
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
|
||||
@@ -696,32 +696,32 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
|
||||
)
|
||||
serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
|
||||
consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_ports')
|
||||
console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_server_ports')
|
||||
console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlets')
|
||||
power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_ports')
|
||||
power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_ports')
|
||||
front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_ports')
|
||||
rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bays')
|
||||
device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bays')
|
||||
module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field()
|
||||
@@ -765,33 +765,36 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
|
||||
airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='console_port_templates')
|
||||
)
|
||||
consoleserverporttemplates: (
|
||||
console_port_templates: (
|
||||
Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
console_server_port_templates: (
|
||||
Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field(name='console_server_port_templates')
|
||||
powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_port_templates')
|
||||
)
|
||||
poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='power_outlet_templates')
|
||||
)
|
||||
interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='interface_templates')
|
||||
)
|
||||
frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='front_port_templates')
|
||||
)
|
||||
rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='rear_port_templates')
|
||||
)
|
||||
devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='device_bay_templates')
|
||||
)
|
||||
modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
|
||||
strawberry_django.filter_field(name='module_bay_templates')
|
||||
)
|
||||
) = strawberry_django.filter_field()
|
||||
power_port_templates: (
|
||||
Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
power_outlet_templates: (
|
||||
Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
interface_templates: (
|
||||
Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
front_port_templates: (
|
||||
Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
rear_port_templates: (
|
||||
Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
device_bay_templates: (
|
||||
Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
module_bay_templates: (
|
||||
Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
inventory_item_templates: (
|
||||
Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
|
||||
) = strawberry_django.filter_field()
|
||||
module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
|
||||
|
||||
|
||||
|
||||
@@ -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', 'tenant',
|
||||
'pk', 'id', 'reservation', 'site', 'location', 'rack', 'unit_list', 'status', 'user', 'created', 'tenant',
|
||||
'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
from django.db import router
|
||||
from django.db.models import signals
|
||||
from taggit.managers import _TaggableManager
|
||||
from taggit.utils import require_instance_manager
|
||||
|
||||
__all__ = (
|
||||
'NetBoxTaggableManager',
|
||||
)
|
||||
|
||||
|
||||
class NetBoxTaggableManager(_TaggableManager):
|
||||
"""
|
||||
Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a
|
||||
single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags.
|
||||
"""
|
||||
|
||||
@require_instance_manager
|
||||
def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs):
|
||||
self._remove_prefetched_objects()
|
||||
if tag_kwargs is None:
|
||||
tag_kwargs = {}
|
||||
db = router.db_for_write(self.through, instance=self.instance)
|
||||
|
||||
tag_objs = self._to_tag_model_instances(tags, tag_kwargs)
|
||||
new_ids = {t.pk for t in tag_objs}
|
||||
|
||||
# Determine which tags are not already assigned to this object
|
||||
lookup = self._lookup_kwargs()
|
||||
vals = set(
|
||||
self.through._default_manager.using(db)
|
||||
.values_list("tag_id", flat=True)
|
||||
.filter(**lookup, tag_id__in=new_ids)
|
||||
)
|
||||
new_ids -= vals
|
||||
|
||||
if not new_ids:
|
||||
return
|
||||
|
||||
signals.m2m_changed.send(
|
||||
sender=self.through,
|
||||
action="pre_add",
|
||||
instance=self.instance,
|
||||
reverse=False,
|
||||
model=self.through.tag_model(),
|
||||
pk_set=new_ids,
|
||||
using=db,
|
||||
)
|
||||
|
||||
# Use a single bulk INSERT instead of one get_or_create per tag.
|
||||
self.through._default_manager.using(db).bulk_create(
|
||||
[
|
||||
self.through(tag=tag, **lookup, **(through_defaults or {}))
|
||||
for tag in tag_objs
|
||||
if tag.pk in new_ids
|
||||
],
|
||||
ignore_conflicts=True,
|
||||
)
|
||||
|
||||
signals.m2m_changed.send(
|
||||
sender=self.through,
|
||||
action="post_add",
|
||||
instance=self.instance,
|
||||
reverse=False,
|
||||
model=self.through.tag_model(),
|
||||
pk_set=new_ids,
|
||||
using=db,
|
||||
)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -53,11 +53,8 @@ class TaggableModelSerializer(serializers.Serializer):
|
||||
|
||||
def _save_tags(self, instance, tags):
|
||||
if tags:
|
||||
# Cache tags on instance so serialize_object() can reuse them without a DB query
|
||||
instance._tags = tags
|
||||
instance.tags.set([t.name for t in tags])
|
||||
else:
|
||||
instance._tags = []
|
||||
instance.tags.clear()
|
||||
|
||||
return instance
|
||||
|
||||
@@ -2,8 +2,6 @@ import strawberry
|
||||
from strawberry.types.unset import UNSET
|
||||
from strawberry_django.pagination import _QS, apply
|
||||
|
||||
from netbox.config import get_config
|
||||
|
||||
__all__ = (
|
||||
'OffsetPaginationInfo',
|
||||
'OffsetPaginationInput',
|
||||
@@ -49,14 +47,4 @@ def apply_pagination(
|
||||
# Ignore `offset` when `start` is set
|
||||
pagination.offset = 0
|
||||
|
||||
# Enforce MAX_PAGE_SIZE on the pagination limit
|
||||
max_page_size = get_config().MAX_PAGE_SIZE
|
||||
if max_page_size:
|
||||
if pagination is None:
|
||||
pagination = OffsetPaginationInput(limit=max_page_size)
|
||||
elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
|
||||
pagination.limit = max_page_size
|
||||
elif pagination.limit <= 0:
|
||||
pagination.limit = max_page_size
|
||||
|
||||
return apply(pagination, queryset, related_field_id=related_field_id)
|
||||
|
||||
@@ -40,24 +40,15 @@ class CoreMiddleware:
|
||||
with apply_request_processors(request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Set or renew the language cookie based on the user's preference. This handles two cases:
|
||||
# 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
|
||||
# the request so we set the cookie here on the login response.
|
||||
# 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
|
||||
# the session expiry.
|
||||
if hasattr(request, '_language_cookie'):
|
||||
language = request._language_cookie
|
||||
elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||
language = request.user.config.get('locale.language')
|
||||
else:
|
||||
language = None
|
||||
if language:
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
# Check if language cookie should be renewed
|
||||
if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
|
||||
if language := request.user.config.get('locale.language'):
|
||||
response.set_cookie(
|
||||
key=settings.LANGUAGE_COOKIE_NAME,
|
||||
value=language,
|
||||
max_age=request.session.get_expiry_age(),
|
||||
secure=settings.SESSION_COOKIE_SECURE,
|
||||
)
|
||||
|
||||
# Attach the unique request ID as an HTTP header.
|
||||
response['X-Request-ID'] = request.id
|
||||
|
||||
@@ -15,7 +15,6 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
from extras.managers import NetBoxTaggableManager
|
||||
from extras.utils import is_taggable
|
||||
from netbox.config import get_config
|
||||
from netbox.constants import CORE_APPS
|
||||
@@ -488,12 +487,11 @@ class JournalingMixin(models.Model):
|
||||
class TagsMixin(models.Model):
|
||||
"""
|
||||
Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
|
||||
which is a `NetBoxTaggableManager` instance.
|
||||
which is a `TaggableManager` instance.
|
||||
"""
|
||||
tags = TaggableManager(
|
||||
through='extras.TaggedItem',
|
||||
ordering=('weight', 'name'),
|
||||
manager=NetBoxTaggableManager,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
|
||||
@@ -283,53 +283,6 @@ class GraphQLAPITestCase(APITestCase):
|
||||
self.assertEqual(len(data['data']['site_list']), 1)
|
||||
self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
|
||||
|
||||
@override_settings(MAX_PAGE_SIZE=3)
|
||||
def test_max_page_size(self):
|
||||
self.add_permissions('dcim.view_site')
|
||||
url = reverse('graphql')
|
||||
|
||||
# Request without explicit limit should be capped by MAX_PAGE_SIZE
|
||||
query = """
|
||||
{
|
||||
site_list {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
|
||||
# Request with limit exceeding MAX_PAGE_SIZE should be capped
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {limit: 100}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site_list']), 3)
|
||||
|
||||
# Request with limit under MAX_PAGE_SIZE should be respected
|
||||
query = """
|
||||
{
|
||||
site_list(pagination: {limit: 2}) {
|
||||
id name
|
||||
}
|
||||
}
|
||||
"""
|
||||
response = self.client.post(url, data={'query': query}, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
data = json.loads(response.content)
|
||||
self.assertNotIn('errors', data)
|
||||
self.assertEqual(len(data['data']['site_list']), 2)
|
||||
|
||||
def test_pagination_conflict(self):
|
||||
url = reverse('graphql')
|
||||
query = """
|
||||
|
||||
@@ -10,7 +10,6 @@ __all__ = (
|
||||
'BooleanAttr',
|
||||
'ChoiceAttr',
|
||||
'ColorAttr',
|
||||
'DateTimeAttr',
|
||||
'GPSCoordinatesAttr',
|
||||
'GenericForeignKeyAttr',
|
||||
'ImageAttr',
|
||||
@@ -368,26 +367,6 @@ class GPSCoordinatesAttr(ObjectAttribute):
|
||||
})
|
||||
|
||||
|
||||
class DateTimeAttr(ObjectAttribute):
|
||||
"""
|
||||
A date or datetime attribute.
|
||||
|
||||
Parameters:
|
||||
spec (str): Controls the rendering format. Use 'date' for date-only rendering,
|
||||
or 'seconds'/'minutes' for datetime rendering with the given precision.
|
||||
"""
|
||||
template_name = 'ui/attrs/datetime.html'
|
||||
|
||||
def __init__(self, *args, spec='seconds', **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.spec = spec
|
||||
|
||||
def get_context(self, obj, context):
|
||||
return {
|
||||
'spec': self.spec,
|
||||
}
|
||||
|
||||
|
||||
class TimezoneAttr(ObjectAttribute):
|
||||
"""
|
||||
A timezone value. Includes the numeric offset from UTC.
|
||||
|
||||
2
netbox/project-static/dist/netbox.js
vendored
2
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -71,7 +71,7 @@ export class DynamicTomSelect extends TomSelect {
|
||||
this.addEventListeners();
|
||||
}
|
||||
|
||||
load(value: string, preserveValue?: string | string[]) {
|
||||
load(value: string) {
|
||||
const self = this;
|
||||
|
||||
// Automatically clear any cached options. (Only options included
|
||||
@@ -107,14 +107,6 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// Pass the options to the callback function
|
||||
.then(options => {
|
||||
self.loadCallback(options, []);
|
||||
// Restore the previous selection if it is still valid under the new filter.
|
||||
if (preserveValue !== undefined) {
|
||||
const values = Array.isArray(preserveValue) ? preserveValue : [preserveValue];
|
||||
const validValues = values.filter(v => v !== '' && v in self.options);
|
||||
if (validValues.length > 0) {
|
||||
self.setValue(validValues.length === 1 ? validValues[0] : validValues, true);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
self.loadCallback([], []);
|
||||
@@ -346,9 +338,6 @@ export class DynamicTomSelect extends TomSelect {
|
||||
private handleEvent(event: Event): void {
|
||||
const target = event.target as HTMLSelectElement;
|
||||
|
||||
// Save the current selection so we can restore it after loading if it remains valid.
|
||||
const previousValue = this.getValue();
|
||||
|
||||
// Update the element's URL after any changes to a dependency.
|
||||
this.updateQueryParams(target.name);
|
||||
this.updatePathValues(target.name);
|
||||
@@ -356,8 +345,7 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// Clear any previous selection(s) as the parent filter has changed
|
||||
this.clear();
|
||||
|
||||
// Load new data, restoring the previous selection if it is still valid under the new filter.
|
||||
const preserve = previousValue !== '' && previousValue !== null ? previousValue : undefined;
|
||||
this.load(this.lastValue, preserve);
|
||||
// Load new data.
|
||||
this.load(this.lastValue);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
{% load helpers %}{% if spec == 'date' %}{{ value|isodate }}{% else %}{{ value|isodatetime:spec }}{% endif %}
|
||||
@@ -1 +0,0 @@
|
||||
{% load helpers %}{{ object.get_full_name|placeholder }}
|
||||
@@ -1,3 +1,60 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
|
||||
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "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>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Users" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for user in object.users.all %}
|
||||
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for perm in object.object_permissions.all %}
|
||||
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Owner Membership" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for owner in object.owners.all %}
|
||||
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,5 +1,93 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
|
||||
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Permission" %}</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 "Enabled" %}</th>
|
||||
<td>{% checkmark object.enabled %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Actions" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "View" %}</th>
|
||||
<td>{% checkmark object.can_view %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Add" %}</th>
|
||||
<td>{% checkmark object.can_add %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Change" %}</th>
|
||||
<td>{% checkmark object.can_change %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Delete" %}</th>
|
||||
<td>{% checkmark object.can_delete %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Constraints" %}</h2>
|
||||
<div class="card-body">
|
||||
{% if object.constraints %}
|
||||
<pre>{{ object.constraints|json }}</pre>
|
||||
{% else %}
|
||||
<span class="text-muted">None</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Object Types" %}</h2>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for user in object.object_types.all %}
|
||||
<li class="list-group-item">{{ user }}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Users" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for user in object.users.all %}
|
||||
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Groups" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for group in object.groups.all %}
|
||||
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,3 +11,50 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Owner" %}</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 "Group" %}</th>
|
||||
<td>{{ object.group|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Description" %}</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Groups" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for group in object.user_groups.all %}
|
||||
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Users" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for user in object.users.all %}
|
||||
<a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
{% include 'inc/panels/related_objects.html' with filter_name='owner_id' %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,3 +1,46 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
{% load helpers %}
|
||||
{% load render_table from django_tables2 %}
|
||||
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block extra_controls %}
|
||||
{% if perms.users.add_owner %}
|
||||
<a href="{% url 'users:owner_add' %}?group={{ object.pk }}" class="btn btn-primary">
|
||||
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Owner" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endblock extra_controls %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "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>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Members" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for owner in object.members.all %}
|
||||
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
{% load i18n %}
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Object Types" %}</h2>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for object_type in object.object_types.all %}
|
||||
<li class="list-group-item">{{ object_type }}</li>
|
||||
{% empty %}
|
||||
<li class="list-group-item text-muted">{% trans "None" %}</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
@@ -1,3 +1,85 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
|
||||
|
||||
{% block subtitle %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "User" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Username" %}</th>
|
||||
<td>{{ object.username }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Full Name" %}</th>
|
||||
<td>{{ object.get_full_name|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Email" %}</th>
|
||||
<td>{{ object.email|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Account Created" %}</th>
|
||||
<td>{{ object.date_joined|isodate }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Last Login" %}</th>
|
||||
<td>{{ object.last_login|isodatetime:"minutes"|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Active" %}</th>
|
||||
<td>{% checkmark object.is_active %}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Superuser" %}</th>
|
||||
<td>{% checkmark object.is_superuser %}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Groups" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for group in object.groups.all %}
|
||||
<a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for perm in object.object_permissions.all %}
|
||||
<a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Owner Membership" %}</h2>
|
||||
<div class="list-group list-group-flush">
|
||||
{% for owner in object.owners.all %}
|
||||
<a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
|
||||
{% empty %}
|
||||
<div class="list-group-item text-muted">{% trans "None" %}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if perms.core.view_objectchange %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
{% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.signals import user_logged_in, user_login_failed
|
||||
from django.contrib.auth.signals import user_login_failed
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
|
||||
@@ -23,18 +23,6 @@ def log_user_login_failed(sender, credentials, request, **kwargs):
|
||||
logger.info(f"Failed login attempt for username: {username}")
|
||||
|
||||
|
||||
@receiver(user_logged_in)
|
||||
def set_language_on_login(sender, user, request, **kwargs):
|
||||
"""
|
||||
Store the user's preferred language on the request so that middleware can set the language cookie. This ensures the
|
||||
language preference is applied even when logging in via an external auth provider (e.g. social-app-django) that
|
||||
does not go through NetBox's LoginView.
|
||||
"""
|
||||
if hasattr(user, 'config'):
|
||||
if language := user.config.get('locale.language'):
|
||||
request._language_cookie = language
|
||||
|
||||
|
||||
@receiver(post_save, sender=User)
|
||||
def create_userconfig(instance, created, raw=False, **kwargs):
|
||||
"""
|
||||
|
||||
@@ -23,38 +23,3 @@ class TokenExamplePanel(panels.Panel):
|
||||
actions = [
|
||||
actions.CopyContent('token-example')
|
||||
]
|
||||
|
||||
|
||||
class UserPanel(panels.ObjectAttributesPanel):
|
||||
username = attrs.TextAttr('username')
|
||||
full_name = attrs.TemplatedAttr(
|
||||
'get_full_name',
|
||||
label=_('Full name'),
|
||||
template_name='users/attrs/full_name.html',
|
||||
)
|
||||
email = attrs.TextAttr('email')
|
||||
date_joined = attrs.DateTimeAttr('date_joined', label=_('Account created'), spec='date')
|
||||
last_login = attrs.DateTimeAttr('last_login', label=_('Last login'), spec='minutes')
|
||||
is_active = attrs.BooleanAttr('is_active', label=_('Active'))
|
||||
is_superuser = attrs.BooleanAttr('is_superuser', label=_('Superuser'))
|
||||
|
||||
|
||||
class ObjectPermissionPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
description = attrs.TextAttr('description')
|
||||
enabled = attrs.BooleanAttr('enabled')
|
||||
|
||||
|
||||
class ObjectPermissionActionsPanel(panels.ObjectAttributesPanel):
|
||||
title = _('Actions')
|
||||
|
||||
can_view = attrs.BooleanAttr('can_view', label=_('View'))
|
||||
can_add = attrs.BooleanAttr('can_add', label=_('Add'))
|
||||
can_change = attrs.BooleanAttr('can_change', label=_('Change'))
|
||||
can_delete = attrs.BooleanAttr('can_delete', label=_('Delete'))
|
||||
|
||||
|
||||
class OwnerPanel(panels.ObjectAttributesPanel):
|
||||
name = attrs.TextAttr('name')
|
||||
group = attrs.RelatedObjectAttr('group', linkify=True)
|
||||
description = attrs.TextAttr('description')
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
from django.db.models import Count
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.models import ObjectChange
|
||||
from core.tables import ObjectChangeTable
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
|
||||
from netbox.ui import actions, layout
|
||||
from netbox.ui.panels import (
|
||||
ContextTablePanel,
|
||||
JSONPanel,
|
||||
ObjectsTablePanel,
|
||||
OrganizationalObjectPanel,
|
||||
RelatedObjectsPanel,
|
||||
TemplatePanel,
|
||||
)
|
||||
from netbox.ui import layout
|
||||
from netbox.views import generic
|
||||
from users.ui import panels
|
||||
from utilities.query import count_related
|
||||
@@ -95,39 +86,7 @@ class UserListView(generic.ObjectListView):
|
||||
@register_model_view(User)
|
||||
class UserView(generic.ObjectView):
|
||||
queryset = User.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.UserPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
ObjectsTablePanel(
|
||||
'users.Group', title=_('Assigned Groups'), filters={'user_id': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
'users.ObjectPermission',
|
||||
title=_('Assigned Permissions'),
|
||||
filters={'user_id': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
'users.Owner', title=_('Owner Membership'), filters={'user_id': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
bottom_panels=[
|
||||
ContextTablePanel(
|
||||
'changelog_table',
|
||||
title=_('Recent Activity'),
|
||||
actions=[
|
||||
actions.LinkAction(
|
||||
view_name='core:objectchange_list',
|
||||
url_params={'user_id': lambda ctx: ctx['object'].pk},
|
||||
label=_('View All'),
|
||||
button_icon='arrow-right-thick',
|
||||
permissions=['core.view_objectchange'],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
template_name = 'users/user.html'
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
changelog = ObjectChange.objects.valid_models().restrict(request.user, 'view').filter(user=instance)[:20]
|
||||
@@ -195,22 +154,7 @@ class GroupListView(generic.ObjectListView):
|
||||
@register_model_view(Group)
|
||||
class GroupView(generic.ObjectView):
|
||||
queryset = Group.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
OrganizationalObjectPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
ObjectsTablePanel('users.User', filters={'group_id': lambda ctx: ctx['object'].pk}),
|
||||
ObjectsTablePanel(
|
||||
'users.ObjectPermission',
|
||||
title=_('Assigned Permissions'),
|
||||
filters={'group_id': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
'users.Owner', title=_('Owner Membership'), filters={'user_group_id': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
)
|
||||
template_name = 'users/group.html'
|
||||
|
||||
|
||||
@register_model_view(Group, 'add', detail=False)
|
||||
@@ -268,22 +212,7 @@ class ObjectPermissionListView(generic.ObjectListView):
|
||||
@register_model_view(ObjectPermission)
|
||||
class ObjectPermissionView(generic.ObjectView):
|
||||
queryset = ObjectPermission.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.ObjectPermissionPanel(),
|
||||
panels.ObjectPermissionActionsPanel(),
|
||||
JSONPanel('constraints', title=_('Constraints')),
|
||||
],
|
||||
right_panels=[
|
||||
TemplatePanel('users/panels/object_types.html'),
|
||||
ObjectsTablePanel(
|
||||
'users.User', title=_('Assigned Users'), filters={'permission_id': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
ObjectsTablePanel(
|
||||
'users.Group', title=_('Assigned Groups'), filters={'permission_id': lambda ctx: ctx['object'].pk}
|
||||
),
|
||||
],
|
||||
)
|
||||
template_name = 'users/objectpermission.html'
|
||||
|
||||
|
||||
@register_model_view(ObjectPermission, 'add', detail=False)
|
||||
@@ -326,7 +255,7 @@ class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(OwnerGroup, 'list', path='', detail=False)
|
||||
class OwnerGroupListView(generic.ObjectListView):
|
||||
queryset = OwnerGroup.objects.annotate(
|
||||
owner_count=count_related(Owner, 'group')
|
||||
owner_count=count_related(Owner, 'group')
|
||||
)
|
||||
filterset = filtersets.OwnerGroupFilterSet
|
||||
filterset_form = forms.OwnerGroupFilterForm
|
||||
@@ -334,26 +263,14 @@ class OwnerGroupListView(generic.ObjectListView):
|
||||
|
||||
|
||||
@register_model_view(OwnerGroup)
|
||||
class OwnerGroupView(generic.ObjectView):
|
||||
class OwnerGroupView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = OwnerGroup.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
OrganizationalObjectPanel(),
|
||||
],
|
||||
right_panels=[
|
||||
ObjectsTablePanel(
|
||||
'users.Owner',
|
||||
filters={'group_id': lambda ctx: ctx['object'].pk},
|
||||
title=_('Members'),
|
||||
actions=[
|
||||
actions.AddObject(
|
||||
'users.Owner',
|
||||
url_params={'group': lambda ctx: ctx['object'].pk},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
)
|
||||
template_name = 'users/ownergroup.html'
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
'related_models': self.get_related_models(request, instance),
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(OwnerGroup, 'add', detail=False)
|
||||
@@ -409,16 +326,7 @@ class OwnerListView(generic.ObjectListView):
|
||||
@register_model_view(Owner)
|
||||
class OwnerView(GetRelatedModelsMixin, generic.ObjectView):
|
||||
queryset = Owner.objects.all()
|
||||
layout = layout.SimpleLayout(
|
||||
left_panels=[
|
||||
panels.OwnerPanel(),
|
||||
ObjectsTablePanel('users.Group', filters={'owner_id': lambda ctx: ctx['object'].pk}),
|
||||
ObjectsTablePanel('users.User', filters={'owner_id': lambda ctx: ctx['object'].pk}),
|
||||
],
|
||||
right_panels=[
|
||||
RelatedObjectsPanel(),
|
||||
],
|
||||
)
|
||||
template_name = 'users/owner.html'
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
return {
|
||||
|
||||
@@ -38,7 +38,6 @@ FILTER_TREENODE_NEGATION_LOOKUP_MAP = dict(
|
||||
# HTTP Request META safe copy
|
||||
#
|
||||
|
||||
# Non-HTTP_ META keys to include when copying a request (whitelist)
|
||||
HTTP_REQUEST_META_SAFE_COPY = [
|
||||
'CONTENT_LENGTH',
|
||||
'CONTENT_TYPE',
|
||||
@@ -62,13 +61,6 @@ HTTP_REQUEST_META_SAFE_COPY = [
|
||||
'SERVER_PORT',
|
||||
]
|
||||
|
||||
# HTTP_ META keys known to carry sensitive data; excluded when copying a request (denylist)
|
||||
HTTP_REQUEST_META_SENSITIVE = {
|
||||
'HTTP_AUTHORIZATION',
|
||||
'HTTP_COOKIE',
|
||||
'HTTP_PROXY_AUTHORIZATION',
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# CSV-style format delimiters
|
||||
|
||||
@@ -8,7 +8,7 @@ from netaddr import AddrFormatError, IPAddress
|
||||
|
||||
from netbox.registry import registry
|
||||
|
||||
from .constants import HTTP_REQUEST_META_SAFE_COPY, HTTP_REQUEST_META_SENSITIVE
|
||||
from .constants import HTTP_REQUEST_META_SAFE_COPY
|
||||
|
||||
__all__ = (
|
||||
'NetBoxFakeRequest',
|
||||
@@ -45,14 +45,11 @@ def copy_safe_request(request, include_files=True):
|
||||
request: The original request object
|
||||
include_files: Whether to include request.FILES.
|
||||
"""
|
||||
meta = {}
|
||||
for k, v in request.META.items():
|
||||
if not isinstance(v, str):
|
||||
continue
|
||||
if k in HTTP_REQUEST_META_SAFE_COPY:
|
||||
meta[k] = v
|
||||
elif k.startswith('HTTP_') and k not in HTTP_REQUEST_META_SENSITIVE:
|
||||
meta[k] = v
|
||||
meta = {
|
||||
k: request.META[k]
|
||||
for k in HTTP_REQUEST_META_SAFE_COPY
|
||||
if k in request.META and isinstance(request.META[k], str)
|
||||
}
|
||||
data = {
|
||||
'META': meta,
|
||||
'COOKIES': request.COOKIES,
|
||||
|
||||
@@ -1,42 +1,7 @@
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.test import RequestFactory, TestCase
|
||||
from netaddr import IPAddress
|
||||
|
||||
from utilities.request import copy_safe_request, get_client_ip
|
||||
|
||||
|
||||
class CopySafeRequestTests(TestCase):
|
||||
def setUp(self):
|
||||
self.factory = RequestFactory()
|
||||
|
||||
def _make_request(self, **kwargs):
|
||||
request = self.factory.get('/', **kwargs)
|
||||
request.user = AnonymousUser()
|
||||
return request
|
||||
|
||||
def test_standard_meta_keys_copied(self):
|
||||
request = self._make_request(HTTP_USER_AGENT='TestAgent/1.0')
|
||||
fake = copy_safe_request(request)
|
||||
self.assertEqual(fake.META.get('HTTP_USER_AGENT'), 'TestAgent/1.0')
|
||||
|
||||
def test_arbitrary_http_headers_copied(self):
|
||||
"""Arbitrary HTTP_ headers (e.g. X-NetBox-*) should be included."""
|
||||
request = self._make_request(HTTP_X_NETBOX_BRANCH='my-branch')
|
||||
fake = copy_safe_request(request)
|
||||
self.assertEqual(fake.META.get('HTTP_X_NETBOX_BRANCH'), 'my-branch')
|
||||
|
||||
def test_sensitive_headers_excluded(self):
|
||||
"""Authorization and Cookie headers must not be copied."""
|
||||
request = self._make_request(HTTP_AUTHORIZATION='Bearer secret')
|
||||
fake = copy_safe_request(request)
|
||||
self.assertNotIn('HTTP_AUTHORIZATION', fake.META)
|
||||
|
||||
def test_non_string_meta_values_excluded(self):
|
||||
"""Non-string META values must not be copied."""
|
||||
request = self._make_request()
|
||||
request.META['HTTP_X_CUSTOM_INT'] = 42
|
||||
fake = copy_safe_request(request)
|
||||
self.assertNotIn('HTTP_X_CUSTOM_INT', fake.META)
|
||||
from utilities.request import get_client_ip
|
||||
|
||||
|
||||
class GetClientIPTests(TestCase):
|
||||
|
||||
@@ -126,8 +126,8 @@ class L2VPNTermination(NetBoxModel):
|
||||
if self.assigned_object:
|
||||
obj_id = self.assigned_object.pk
|
||||
obj_type = ObjectType.objects.get_for_model(self.assigned_object)
|
||||
terminations = L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type)
|
||||
if terminations.exclude(pk=self.pk).exists():
|
||||
if L2VPNTermination.objects.filter(assigned_object_id=obj_id, assigned_object_type=obj_type).\
|
||||
exclude(pk=self.pk).count() > 0:
|
||||
raise ValidationError(
|
||||
_('L2VPN Termination already assigned ({assigned_object})').format(
|
||||
assigned_object=self.assigned_object
|
||||
|
||||
Reference in New Issue
Block a user