Merge branch 'develop' into feature

This commit is contained in:
Jeremy Stretch 2024-08-29 10:51:38 -04:00
commit 6f7bf5baf4
90 changed files with 6932 additions and 7066 deletions

View File

@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.0.9
placeholder: v4.0.10
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.0.9
placeholder: v4.0.10
validations:
required: true
- type: dropdown

View File

@ -1,21 +0,0 @@
# auto-assign-issue (https://github.com/marketplace/actions/auto-assign-issue)
name: Issue assignment
on:
issues:
types: [opened]
permissions:
issues: write
jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- uses: pozil/auto-assign-issue@v2
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
with:
# Weighted assignments
assignees: arthanson:3, jeremystretch:3, DanSheps
numOfAssignee: 1
abortIfPreviousAssignees: true

View File

@ -5,7 +5,7 @@
Default: False
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only
clients which access NetBox from a recognized [internal IP address](#internal_ips) will see debugging tools in the user
clients which access NetBox from a recognized [internal IP address](./system.md#internal_ips) will see debugging tools in the user
interface.
!!! warning

View File

@ -83,7 +83,7 @@ Default: `('127.0.0.1', '::1')`
A list of IP addresses recognized as internal to the system, used to control the display of debugging output. For
example, the debugging toolbar will be viewable only when a client is accessing NetBox from one of the listed IP
addresses (and [`DEBUG`](#debug) is true).
addresses (and [`DEBUG`](./development.md#debug) is true).
---
@ -117,7 +117,7 @@ JINJA2_FILTERS = {
## LOGGING
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](#admins).
By default, all messages of INFO severity or higher will be logged to the console. Additionally, if [`DEBUG`](./development.md#debug) is False and email access has been configured, ERROR and CRITICAL messages will be emailed to the users defined in [`ADMINS`](./miscellaneous.md#admins).
The Django framework on which NetBox runs allows for the customization of logging format and destination. Please consult the [Django logging documentation](https://docs.djangoproject.com/en/stable/topics/logging/) for more information on configuring this setting. Below is an example which will write all INFO and higher messages to a local file:

View File

@ -19,7 +19,7 @@ Sometimes it becomes necessary to constrain dependencies to a particular version
djangorestframework==3.8.1
```
These version constraints are added to `base_requirements.txt` to ensure that newer packages are not installed when updating the pinned dependencies in `requirements.txt` (see the [Update Requirements](#update-requirements) section below). Before each new minor version of NetBox is released, all such constraints on dependent packages should be addressed if feasible. This guards against the collection of stale constraints over time.
These version constraints are added to `base_requirements.txt` to ensure that newer packages are not installed when updating the pinned dependencies in `requirements.txt` (see the [Update Requirements](#update-python-dependencies) section below). Before each new minor version of NetBox is released, all such constraints on dependent packages should be addressed if feasible. This guards against the collection of stale constraints over time.
### Close the Release Milestone

View File

@ -41,7 +41,7 @@ Line breaks are permitted following binary operators.
### Enforcing Code Style
The [`pycodestyle`](https://pypi.org/project/pycodestyle/) utility (formerly `pep8`) is used by the CI process to enforce code style. A [pre-commit hook](./getting-started.md#2-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `pycodestyle` manually, run:
The [`pycodestyle`](https://pypi.org/project/pycodestyle/) utility (formerly `pep8`) is used by the CI process to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `pycodestyle` manually, run:
```
pycodestyle --ignore=W504,E501 netbox/

View File

@ -1,6 +1,38 @@
# NetBox v4.0
## v4.0.10 (FUTURE)
## v4.0.10 (2024-08-29)
### Enhancements
* [#16857](https://github.com/netbox-community/netbox/issues/16857) - Scroll long rendered Markdown content within tables
* [#16905](https://github.com/netbox-community/netbox/issues/16905) - Enable filtering of device components by device status
* [#16949](https://github.com/netbox-community/netbox/issues/16949) - Add device count column to sites table
* [#17072](https://github.com/netbox-community/netbox/issues/17072) - Linkify email addresses & phone numbers in contact assignments list
* [#17177](https://github.com/netbox-community/netbox/issues/17177) - Add facility field to locations filter form
### Bug Fixes
* [#16292](https://github.com/netbox-community/netbox/issues/16292) - Ensure consistent evaluation of queryset for both individual and list GraphQL API queries
* [#16385](https://github.com/netbox-community/netbox/issues/16385) - Restore support for white, gray, and black background colors
* [#16640](https://github.com/netbox-community/netbox/issues/16640) - Fix potential corruption of JSON values in custom fields that are not UI-editable
* [#16670](https://github.com/netbox-community/netbox/issues/16670) - Fix conflicts within OpenAPI schema definition regarding nested serializers
* [#16733](https://github.com/netbox-community/netbox/issues/16733) - Fix bulk edit/delete of objects when using "select all" widget
* [#16756](https://github.com/netbox-community/netbox/issues/16756) - Fix dynamic pagination of custom script results table
* [#16825](https://github.com/netbox-community/netbox/issues/16825) - Avoid `NoReverseMatch` exception when displaying count of related object type with no list view
* [#16946](https://github.com/netbox-community/netbox/issues/16946) - GraphQL API requests with an invalid filter should return an empty set
* [#16959](https://github.com/netbox-community/netbox/issues/16959) - Fix function of "reset" button on objects filter form
* [#16973](https://github.com/netbox-community/netbox/issues/16973) - Fix support for evaluating user token (`$user`) against custom field values in permission constraints
* [#17007](https://github.com/netbox-community/netbox/issues/17007) - Center SSO authentication icon when backend is unnamed
* [#17070](https://github.com/netbox-community/netbox/issues/17070) - Image height & width values should not be required when creating an image attachment via the REST API
* [#17108](https://github.com/netbox-community/netbox/issues/17108) - Ensure template date & time filters always return localtime-aware values
* [#17117](https://github.com/netbox-community/netbox/issues/17117) - Work around Safari rendering bug
* [#17186](https://github.com/netbox-community/netbox/issues/17186) - Fix display of custom links with default style under dark mode
* [#17219](https://github.com/netbox-community/netbox/issues/17219) - Fix system config view exception when custom validator classes are employed
* [#17230](https://github.com/netbox-community/netbox/issues/17230) - Ensure consistent rendering for all dashboard widget colors
* [#17256](https://github.com/netbox-community/netbox/issues/17256) - Fix VLAN group scope selection for non-English languages
* [#17278](https://github.com/netbox-community/netbox/issues/17278) - Ensure hierarchy is recalculated when bulk editing recursively nested object types (e.g. tenant groups)
* [#17279](https://github.com/netbox-community/netbox/issues/17279) - Do not regenerate key when updating a token via REST API
* [#17286](https://github.com/netbox-community/netbox/issues/17286) - Fix exception when adding member device to virtual chassis via web UI
---

View File

@ -3,48 +3,31 @@ from typing import List
import strawberry
import strawberry_django
from circuits import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class CircuitsQuery:
@strawberry.field
def circuit(self, id: int) -> CircuitType:
return models.Circuit.objects.get(pk=id)
circuit: CircuitType = strawberry_django.field()
circuit_list: List[CircuitType] = strawberry_django.field()
@strawberry.field
def circuit_termination(self, id: int) -> CircuitTerminationType:
return models.CircuitTermination.objects.get(pk=id)
circuit_termination: CircuitTerminationType = strawberry_django.field()
circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field()
@strawberry.field
def circuit_type(self, id: int) -> CircuitTypeType:
return models.CircuitType.objects.get(pk=id)
circuit_type: CircuitTypeType = strawberry_django.field()
circuit_type_list: List[CircuitTypeType] = strawberry_django.field()
@strawberry.field
def circuit_group(self, id: int) -> CircuitGroupType:
return models.CircuitGroup.objects.get(pk=id)
circuit_group: CircuitGroupType = strawberry_django.field()
circuit_group_list: List[CircuitGroupType] = strawberry_django.field()
@strawberry.field
def circuit_group_assignment(self, id: int) -> CircuitGroupAssignmentType:
return models.CircuitGroupAssignment.objects.get(pk=id)
circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field()
@strawberry.field
def provider(self, id: int) -> ProviderType:
return models.Provider.objects.get(pk=id)
provider: ProviderType = strawberry_django.field()
provider_list: List[ProviderType] = strawberry_django.field()
@strawberry.field
def provider_account(self, id: int) -> ProviderAccountType:
return models.ProviderAccount.objects.get(pk=id)
provider_account: ProviderAccountType = strawberry_django.field()
provider_account_list: List[ProviderAccountType] = strawberry_django.field()
@strawberry.field
def provider_network(self, id: int) -> ProviderNetworkType:
return models.ProviderNetwork.objects.get(pk=id)
provider_network: ProviderNetworkType = strawberry_django.field()
provider_network_list: List[ProviderNetworkType] = strawberry_django.field()

View File

@ -1,5 +1,3 @@
# Generated by Django 5.0.7 on 2024-07-22 06:27
import django.db.models.deletion
import taggit.managers
import utilities.json
@ -10,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [
('circuits', '0043_circuittype_color'),
('extras', '0118_notifications'),
('extras', '0119_notifications'),
('tenancy', '0015_contactassignment_rename_content_type'),
]

View File

@ -126,9 +126,18 @@ class NetBoxAutoSchema(AutoSchema):
return response_serializers
def _get_serializer_name(self, serializer, direction, bypass_extensions=False) -> str:
name = super()._get_serializer_name(serializer, direction, bypass_extensions)
# If this serializer is nested, prepend its name with "Brief"
if getattr(serializer, 'nested', False):
name = f'Brief{name}'
return name
def get_serializer_ref_name(self, serializer):
# from drf-yasg.utils
"""Get serializer's ref_name (or None for ModelSerializer if it is named 'NestedSerializer')
"""Get serializer's ref_name
:param serializer: Serializer instance
:return: Serializer's ``ref_name`` or ``None`` for inline serializer
:rtype: str or None
@ -137,8 +146,6 @@ class NetBoxAutoSchema(AutoSchema):
serializer_name = type(serializer).__name__
if hasattr(serializer_meta, 'ref_name'):
ref_name = serializer_meta.ref_name
elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer):
ref_name = None
else:
ref_name = serializer_name
if ref_name.endswith('Serializer'):

View File

@ -3,18 +3,13 @@ from typing import List
import strawberry
import strawberry_django
from core import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class CoreQuery:
@strawberry.field
def data_file(self, id: int) -> DataFileType:
return models.DataFile.objects.get(pk=id)
data_file: DataFileType = strawberry_django.field()
data_file_list: List[DataFileType] = strawberry_django.field()
@strawberry.field
def data_source(self, id: int) -> DataSourceType:
return models.DataSource.objects.get(pk=id)
data_source: DataSourceType = strawberry_django.field()
data_source_list: List[DataSourceType] = strawberry_django.field()

View File

@ -2,7 +2,6 @@ import json
import platform
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
@ -32,6 +31,7 @@ from netbox.views.generic.mixins import TableMixin
from utilities.data import shallow_compare_dict
from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder
from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
@ -643,10 +643,14 @@ class SystemView(UserPassesTestMixin, View):
k: getattr(config, k) for k in sorted(params)
},
}
response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
response = HttpResponse(json.dumps(data, cls=ConfigJSONEncoder, indent=4), content_type='text/json')
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
# Serialize any CustomValidator classes
if hasattr(config, 'CUSTOM_VALIDATORS') and config.CUSTOM_VALIDATORS:
config.CUSTOM_VALIDATORS = json.dumps(config.CUSTOM_VALIDATORS, cls=ConfigJSONEncoder, indent=4)
return render(request, 'core/system.html', {
'stats': stats,
'config': config,

View File

@ -1463,6 +1463,10 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
to_field_name='name',
label=_('Virtual Chassis'),
)
device_status = django_filters.MultipleChoiceFilter(
choices=DeviceStatusChoices,
field_name='device__status',
)
def search(self, queryset, name, value):
if not value.strip():

View File

@ -130,6 +130,11 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm):
},
label=_('Device')
)
device_status = forms.MultipleChoiceField(
choices=DeviceStatusChoices,
required=False,
label=_('Device Status'),
)
class RegionFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm):
@ -196,7 +201,7 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
model = Location
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'parent_id', 'status', 'facility', name=_('Attributes')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
@ -233,6 +238,10 @@ class LocationFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelF
choices=LocationStatusChoices,
required=False
)
facility = forms.CharField(
label=_('Facility'),
required=False
)
tag = TagFilterField(model)
@ -1229,7 +1238,9 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
type = forms.MultipleChoiceField(
@ -1251,7 +1262,10 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'speed', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
type = forms.MultipleChoiceField(
@ -1273,7 +1287,9 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
type = forms.MultipleChoiceField(
@ -1290,7 +1306,10 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
type = forms.MultipleChoiceField(
@ -1310,7 +1329,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
FieldSet('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power', name=_('Wireless')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', 'vdc_id',
name=_('Device')
),
FieldSet('cabled', 'connected', 'occupied', name=_('Connection')),
)
selector_fields = ('filter_id', 'q', 'device_id')
@ -1418,7 +1440,9 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id', name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
)
model = FrontPort
@ -1440,7 +1464,10 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'type', 'color', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
FieldSet('cabled', 'occupied', name=_('Cable')),
)
type = forms.MultipleChoiceField(
@ -1461,7 +1488,10 @@ class ModuleBayFilterForm(DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', 'position', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
tag = TagFilterField(model)
position = forms.CharField(
@ -1476,7 +1506,10 @@ class DeviceBayFilterForm(DeviceComponentFilterForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('name', 'label', name=_('Attributes')),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
tag = TagFilterField(model)
@ -1490,7 +1523,10 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
name=_('Attributes')
),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', name=_('Location')),
FieldSet('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', name=_('Device')),
FieldSet(
'device_type_id', 'device_role_id', 'device_id', 'device_status', 'virtual_chassis_id',
name=_('Device')
),
)
role_id = DynamicModelMultipleChoiceField(
queryset=InventoryItemRole.objects.all(),

View File

@ -3,213 +3,130 @@ from typing import List
import strawberry
import strawberry_django
from dcim import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class DCIMQuery:
@strawberry.field
def cable(self, id: int) -> CableType:
return models.Cable.objects.get(pk=id)
cable: CableType = strawberry_django.field()
cable_list: List[CableType] = strawberry_django.field()
@strawberry.field
def console_port(self, id: int) -> ConsolePortType:
return models.ConsolePort.objects.get(pk=id)
console_port: ConsolePortType = strawberry_django.field()
console_port_list: List[ConsolePortType] = strawberry_django.field()
@strawberry.field
def console_port_template(self, id: int) -> ConsolePortTemplateType:
return models.ConsolePortTemplate.objects.get(pk=id)
console_port_template: ConsolePortTemplateType = strawberry_django.field()
console_port_template_list: List[ConsolePortTemplateType] = strawberry_django.field()
@strawberry.field
def console_server_port(self, id: int) -> ConsoleServerPortType:
return models.ConsoleServerPort.objects.get(pk=id)
console_server_port: ConsoleServerPortType = strawberry_django.field()
console_server_port_list: List[ConsoleServerPortType] = strawberry_django.field()
@strawberry.field
def console_server_port_template(self, id: int) -> ConsoleServerPortTemplateType:
return models.ConsoleServerPortTemplate.objects.get(pk=id)
console_server_port_template: ConsoleServerPortTemplateType = strawberry_django.field()
console_server_port_template_list: List[ConsoleServerPortTemplateType] = strawberry_django.field()
@strawberry.field
def device(self, id: int) -> DeviceType:
return models.Device.objects.get(pk=id)
device: DeviceType = strawberry_django.field()
device_list: List[DeviceType] = strawberry_django.field()
@strawberry.field
def device_bay(self, id: int) -> DeviceBayType:
return models.DeviceBay.objects.get(pk=id)
device_bay: DeviceBayType = strawberry_django.field()
device_bay_list: List[DeviceBayType] = strawberry_django.field()
@strawberry.field
def device_bay_template(self, id: int) -> DeviceBayTemplateType:
return models.DeviceBayTemplate.objects.get(pk=id)
device_bay_template: DeviceBayTemplateType = strawberry_django.field()
device_bay_template_list: List[DeviceBayTemplateType] = strawberry_django.field()
@strawberry.field
def device_role(self, id: int) -> DeviceRoleType:
return models.DeviceRole.objects.get(pk=id)
device_role: DeviceRoleType = strawberry_django.field()
device_role_list: List[DeviceRoleType] = strawberry_django.field()
@strawberry.field
def device_type(self, id: int) -> DeviceTypeType:
return models.DeviceType.objects.get(pk=id)
device_type: DeviceTypeType = strawberry_django.field()
device_type_list: List[DeviceTypeType] = strawberry_django.field()
@strawberry.field
def front_port(self, id: int) -> FrontPortType:
return models.FrontPort.objects.get(pk=id)
front_port: FrontPortType = strawberry_django.field()
front_port_list: List[FrontPortType] = strawberry_django.field()
@strawberry.field
def front_port_template(self, id: int) -> FrontPortTemplateType:
return models.FrontPortTemplate.objects.get(pk=id)
front_port_template: FrontPortTemplateType = strawberry_django.field()
front_port_template_list: List[FrontPortTemplateType] = strawberry_django.field()
@strawberry.field
def interface(self, id: int) -> InterfaceType:
return models.Interface.objects.get(pk=id)
interface: InterfaceType = strawberry_django.field()
interface_list: List[InterfaceType] = strawberry_django.field()
@strawberry.field
def interface_template(self, id: int) -> InterfaceTemplateType:
return models.InterfaceTemplate.objects.get(pk=id)
interface_template: InterfaceTemplateType = strawberry_django.field()
interface_template_list: List[InterfaceTemplateType] = strawberry_django.field()
@strawberry.field
def inventory_item(self, id: int) -> InventoryItemType:
return models.InventoryItem.objects.get(pk=id)
inventory_item: InventoryItemType = strawberry_django.field()
inventory_item_list: List[InventoryItemType] = strawberry_django.field()
@strawberry.field
def inventory_item_role(self, id: int) -> InventoryItemRoleType:
return models.InventoryItemRole.objects.get(pk=id)
inventory_item_role: InventoryItemRoleType = strawberry_django.field()
inventory_item_role_list: List[InventoryItemRoleType] = strawberry_django.field()
@strawberry.field
def inventory_item_template(self, id: int) -> InventoryItemTemplateType:
return models.InventoryItemTemplate.objects.get(pk=id)
inventory_item_template: InventoryItemTemplateType = strawberry_django.field()
inventory_item_template_list: List[InventoryItemTemplateType] = strawberry_django.field()
@strawberry.field
def location(self, id: int) -> LocationType:
return models.Location.objects.get(pk=id)
location: LocationType = strawberry_django.field()
location_list: List[LocationType] = strawberry_django.field()
@strawberry.field
def manufacturer(self, id: int) -> ManufacturerType:
return models.Manufacturer.objects.get(pk=id)
manufacturer: ManufacturerType = strawberry_django.field()
manufacturer_list: List[ManufacturerType] = strawberry_django.field()
@strawberry.field
def module(self, id: int) -> ModuleType:
return models.Module.objects.get(pk=id)
module: ModuleType = strawberry_django.field()
module_list: List[ModuleType] = strawberry_django.field()
@strawberry.field
def module_bay(self, id: int) -> ModuleBayType:
return models.ModuleBay.objects.get(pk=id)
module_bay: ModuleBayType = strawberry_django.field()
module_bay_list: List[ModuleBayType] = strawberry_django.field()
@strawberry.field
def module_bay_template(self, id: int) -> ModuleBayTemplateType:
return models.ModuleBayTemplate.objects.get(pk=id)
module_bay_template: ModuleBayTemplateType = strawberry_django.field()
module_bay_template_list: List[ModuleBayTemplateType] = strawberry_django.field()
@strawberry.field
def module_type(self, id: int) -> ModuleTypeType:
return models.ModuleType.objects.get(pk=id)
module_type: ModuleTypeType = strawberry_django.field()
module_type_list: List[ModuleTypeType] = strawberry_django.field()
@strawberry.field
def platform(self, id: int) -> PlatformType:
return models.Platform.objects.get(pk=id)
platform: PlatformType = strawberry_django.field()
platform_list: List[PlatformType] = strawberry_django.field()
@strawberry.field
def power_feed(self, id: int) -> PowerFeedType:
return models.PowerFeed.objects.get(pk=id)
power_feed: PowerFeedType = strawberry_django.field()
power_feed_list: List[PowerFeedType] = strawberry_django.field()
@strawberry.field
def power_outlet(self, id: int) -> PowerOutletType:
return models.PowerOutlet.objects.get(pk=id)
power_outlet: PowerOutletType = strawberry_django.field()
power_outlet_list: List[PowerOutletType] = strawberry_django.field()
@strawberry.field
def power_outlet_template(self, id: int) -> PowerOutletTemplateType:
return models.PowerOutletTemplate.objects.get(pk=id)
power_outlet_template: PowerOutletTemplateType = strawberry_django.field()
power_outlet_template_list: List[PowerOutletTemplateType] = strawberry_django.field()
@strawberry.field
def power_panel(self, id: int) -> PowerPanelType:
return models.PowerPanel.objects.get(id=id)
power_panel: PowerPanelType = strawberry_django.field()
power_panel_list: List[PowerPanelType] = strawberry_django.field()
@strawberry.field
def power_port(self, id: int) -> PowerPortType:
return models.PowerPort.objects.get(id=id)
power_port: PowerPortType = strawberry_django.field()
power_port_list: List[PowerPortType] = strawberry_django.field()
@strawberry.field
def power_port_template(self, id: int) -> PowerPortTemplateType:
return models.PowerPortTemplate.objects.get(id=id)
power_port_template: PowerPortTemplateType = strawberry_django.field()
power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field()
@strawberry.field
def rack_type(self, id: int) -> RackTypeType:
return models.RackType.objects.get(id=id)
rack_type: RackTypeType = strawberry_django.field()
rack_type_list: List[RackTypeType] = strawberry_django.field()
@strawberry.field
def rack(self, id: int) -> RackType:
return models.Rack.objects.get(id=id)
rack: RackType = strawberry_django.field()
rack_list: List[RackType] = strawberry_django.field()
@strawberry.field
def rack_reservation(self, id: int) -> RackReservationType:
return models.RackReservation.objects.get(id=id)
rack_reservation: RackReservationType = strawberry_django.field()
rack_reservation_list: List[RackReservationType] = strawberry_django.field()
@strawberry.field
def rack_role(self, id: int) -> RackRoleType:
return models.RackRole.objects.get(id=id)
rack_role: RackRoleType = strawberry_django.field()
rack_role_list: List[RackRoleType] = strawberry_django.field()
@strawberry.field
def rear_port(self, id: int) -> RearPortType:
return models.RearPort.objects.get(id=id)
rear_port: RearPortType = strawberry_django.field()
rear_port_list: List[RearPortType] = strawberry_django.field()
@strawberry.field
def rear_port_template(self, id: int) -> RearPortTemplateType:
return models.RearPortTemplate.objects.get(id=id)
rear_port_template: RearPortTemplateType = strawberry_django.field()
rear_port_template_list: List[RearPortTemplateType] = strawberry_django.field()
@strawberry.field
def region(self, id: int) -> RegionType:
return models.Region.objects.get(id=id)
region: RegionType = strawberry_django.field()
region_list: List[RegionType] = strawberry_django.field()
@strawberry.field
def site(self, id: int) -> SiteType:
return models.Site.objects.get(id=id)
site: SiteType = strawberry_django.field()
site_list: List[SiteType] = strawberry_django.field()
@strawberry.field
def site_group(self, id: int) -> SiteGroupType:
return models.SiteGroup.objects.get(id=id)
site_group: SiteGroupType = strawberry_django.field()
site_group_list: List[SiteGroupType] = strawberry_django.field()
@strawberry.field
def virtual_chassis(self, id: int) -> VirtualChassisType:
return models.VirtualChassis.objects.get(id=id)
virtual_chassis: VirtualChassisType = strawberry_django.field()
virtual_chassis_list: List[VirtualChassisType] = strawberry_django.field()
@strawberry.field
def virtual_device_context(self, id: int) -> VirtualDeviceContextType:
return models.VirtualDeviceContext.objects.get(id=id)
virtual_device_context: VirtualDeviceContextType = strawberry_django.field()
virtual_device_context_list: List[VirtualDeviceContextType] = strawberry_django.field()

View File

@ -11,7 +11,7 @@ import utilities.ordering
class Migration(migrations.Migration):
dependencies = [
('extras', '0117_customfield_uniqueness'),
('extras', '0118_customfield_uniqueness'),
('dcim', '0187_alter_device_vc_position'),
]

View File

@ -7,7 +7,7 @@ class Migration(migrations.Migration):
dependencies = [
('dcim', '0189_moduletype_airflow_rack_airflow_racktype_airflow'),
('extras', '0120_customfield_related_object_filter'),
('extras', '0121_customfield_related_object_filter'),
]
operations = [

View File

@ -290,6 +290,11 @@ class DeviceComponentTable(NetBoxTable):
linkify=True,
order_by=('_name',)
)
device_status = columns.ChoiceFieldColumn(
accessor=tables.A('device__status'),
verbose_name=_('Device Status'),
color=lambda x: x.device.get_status_color(),
)
class Meta(NetBoxTable.Meta):
order_by = ('device', 'name')

View File

@ -99,6 +99,11 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
url_params={'site_id': 'pk'},
verbose_name=_('ASN Count')
)
device_count = columns.LinkedCountColumn(
viewname='dcim:device_list',
url_params={'site_id': 'pk'},
verbose_name=_('Devices')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)

View File

@ -37,6 +37,10 @@ class DeviceComponentFilterSetTests:
params = {'device_role': [role[0].slug, role[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_device_status(self):
params = {'device_status': ['active', 'planned']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class DeviceComponentTemplateFilterSetTests:
@ -2825,10 +2829,10 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[0], role=roles[0], site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
Device(name=None, device_type=device_types[0], role=roles[0], site=sites[3], status='offline'), # For cable connections
)
Device.objects.bulk_create(devices)
@ -3006,10 +3010,10 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3], status='offline'), # For cable connections
)
Device.objects.bulk_create(devices)
@ -3187,10 +3191,10 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3], status='offline'), # For cable connections
)
Device.objects.bulk_create(devices)
@ -3376,10 +3380,10 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3], status='offline'), # For cable connections
)
Device.objects.bulk_create(devices)
@ -3575,7 +3579,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rack=racks[0],
virtual_chassis=virtual_chassis,
vc_position=1,
vc_priority=1
vc_priority=1,
status='active',
),
Device(
name='Device 1B',
@ -3586,7 +3591,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
rack=racks[2],
virtual_chassis=virtual_chassis,
vc_position=2,
vc_priority=1
vc_priority=1,
status='planned',
),
Device(
name='Device 2',
@ -3594,7 +3600,8 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
role=roles[1],
site=sites[1],
location=locations[1],
rack=racks[1]
rack=racks[1],
status='offline',
),
Device(
name='Device 3',
@ -3602,14 +3609,16 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
role=roles[2],
site=sites[2],
location=locations[2],
rack=racks[2]
rack=racks[2],
status='offline',
),
# For cable connections
Device(
name=None,
device_type=device_types[2],
role=roles[2],
site=sites[3]
site=sites[3],
status='offline',
),
)
Device.objects.bulk_create(devices)
@ -4056,10 +4065,10 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3], status='offline'), # For cable connections
)
Device.objects.bulk_create(devices)
@ -4246,10 +4255,10 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3]), # For cable connections
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
Device(name=None, device_type=device_types[2], role=roles[2], site=sites[3], status='offline'), # For cable connections
)
Device.objects.bulk_create(devices)
@ -4428,9 +4437,9 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
)
Device.objects.bulk_create(devices)
@ -4576,9 +4585,9 @@ class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
Rack.objects.bulk_create(racks)
devices = (
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0]),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1]),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2]),
Device(name='Device 1', device_type=device_types[0], role=roles[0], site=sites[0], location=locations[0], rack=racks[0], status='active'),
Device(name='Device 2', device_type=device_types[1], role=roles[1], site=sites[1], location=locations[1], rack=racks[1], status='planned'),
Device(name='Device 3', device_type=device_types[2], role=roles[2], site=sites[2], location=locations[2], rack=racks[2], status='offline'),
)
Device.objects.bulk_create(devices)

View File

@ -380,7 +380,9 @@ class SiteGroupContactsView(ObjectContactsView):
#
class SiteListView(generic.ObjectListView):
queryset = Site.objects.all()
queryset = Site.objects.annotate(
device_count=count_related(Device, 'site')
)
filterset = filtersets.SiteFilterSet
filterset_form = forms.SiteFilterForm
table = tables.SiteTable
@ -3505,7 +3507,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
membership_form.save()
messages.success(request, mark_safe(
_('Added member <a href="{url}">{escape(device)}</a>').format(url=device.get_absolute_url())
_('Added member <a href="{url}">{device}</a>').format(url=device.get_absolute_url(), device=escape(device))
))
if '_addanother' in request.POST:

View File

@ -18,6 +18,8 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
queryset=ObjectType.objects.all()
)
parent = serializers.SerializerMethodField(read_only=True)
image_width = serializers.IntegerField(read_only=True)
image_height = serializers.IntegerField(read_only=True)
class Meta:
model = ImageAttachment

View File

@ -131,22 +131,6 @@ class DashboardWidget:
def name(self):
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}'
@property
def fg_color(self):
"""
Return the appropriate foreground (text) color for the widget's color.
"""
if self.color in (
ButtonColorChoices.CYAN,
ButtonColorChoices.GRAY,
ButtonColorChoices.GREY,
ButtonColorChoices.TEAL,
ButtonColorChoices.WHITE,
ButtonColorChoices.YELLOW,
):
return ButtonColorChoices.BLACK
return ButtonColorChoices.WHITE
@property
def form_data(self):
return {

View File

@ -31,7 +31,7 @@ class ReportForm(forms.Form):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
now = local_now().strftime('%Y-%m-%d %H:%M:%S %Z')
self.fields['schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now)
# Remove scheduling fields if scheduling is disabled

View File

@ -37,7 +37,7 @@ class ScriptForm(forms.Form):
super().__init__(*args, **kwargs)
# Annotate the current system time for reference
now = local_now().strftime('%Y-%m-%d %H:%M:%S')
now = local_now().strftime('%Y-%m-%d %H:%M:%S %Z')
self.fields['_schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now)
# Remove scheduling fields if scheduling is disabled

View File

@ -3,83 +3,52 @@ from typing import List
import strawberry
import strawberry_django
from extras import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class ExtrasQuery:
@strawberry.field
def config_context(self, id: int) -> ConfigContextType:
return models.ConfigContext.objects.get(pk=id)
config_context: ConfigContextType = strawberry_django.field()
config_context_list: List[ConfigContextType] = strawberry_django.field()
@strawberry.field
def config_template(self, id: int) -> ConfigTemplateType:
return models.ConfigTemplate.objects.get(pk=id)
config_template: ConfigTemplateType = strawberry_django.field()
config_template_list: List[ConfigTemplateType] = strawberry_django.field()
@strawberry.field
def custom_field(self, id: int) -> CustomFieldType:
return models.CustomField.objects.get(pk=id)
custom_field: CustomFieldType = strawberry_django.field()
custom_field_list: List[CustomFieldType] = strawberry_django.field()
@strawberry.field
def custom_field_choice_set(self, id: int) -> CustomFieldChoiceSetType:
return models.CustomFieldChoiceSet.objects.get(pk=id)
custom_field_choice_set: CustomFieldChoiceSetType = strawberry_django.field()
custom_field_choice_set_list: List[CustomFieldChoiceSetType] = strawberry_django.field()
@strawberry.field
def custom_link(self, id: int) -> CustomLinkType:
return models.CustomLink.objects.get(pk=id)
custom_link: CustomLinkType = strawberry_django.field()
custom_link_list: List[CustomLinkType] = strawberry_django.field()
@strawberry.field
def export_template(self, id: int) -> ExportTemplateType:
return models.ExportTemplate.objects.get(pk=id)
export_template: ExportTemplateType = strawberry_django.field()
export_template_list: List[ExportTemplateType] = strawberry_django.field()
@strawberry.field
def image_attachment(self, id: int) -> ImageAttachmentType:
return models.ImageAttachment.objects.get(pk=id)
image_attachment: ImageAttachmentType = strawberry_django.field()
image_attachment_list: List[ImageAttachmentType] = strawberry_django.field()
@strawberry.field
def saved_filter(self, id: int) -> SavedFilterType:
return models.SavedFilter.objects.get(pk=id)
saved_filter: SavedFilterType = strawberry_django.field()
saved_filter_list: List[SavedFilterType] = strawberry_django.field()
@strawberry.field
def journal_entry(self, id: int) -> JournalEntryType:
return models.JournalEntry.objects.get(pk=id)
journal_entry: JournalEntryType = strawberry_django.field()
journal_entry_list: List[JournalEntryType] = strawberry_django.field()
@strawberry.field
def notification(self, id: int) -> NotificationType:
return models.Notification.objects.get(pk=id)
notification: NotificationType = strawberry_django.field()
notification_list: List[NotificationType] = strawberry_django.field()
@strawberry.field
def notification_group(self, id: int) -> NotificationGroupType:
return models.NotificationGroup.objects.get(pk=id)
notification_group: NotificationGroupType = strawberry_django.field()
notification_group_list: List[NotificationGroupType] = strawberry_django.field()
@strawberry.field
def subscription(self, id: int) -> SubscriptionType:
return models.Subscription.objects.get(pk=id)
subscription: SubscriptionType = strawberry_django.field()
subscription_list: List[SubscriptionType] = strawberry_django.field()
@strawberry.field
def tag(self, id: int) -> TagType:
return models.Tag.objects.get(pk=id)
tag: TagType = strawberry_django.field()
tag_list: List[TagType] = strawberry_django.field()
@strawberry.field
def webhook(self, id: int) -> WebhookType:
return models.Webhook.objects.get(pk=id)
webhook: WebhookType = strawberry_django.field()
webhook_list: List[WebhookType] = strawberry_django.field()
@strawberry.field
def event_rule(self, id: int) -> EventRuleType:
return models.EventRule.objects.get(pk=id)
event_rule: EventRuleType = strawberry_django.field()
event_rule_list: List[EventRuleType] = strawberry_django.field()

View File

@ -0,0 +1,25 @@
from django.db import migrations, models
def update_link_buttons(apps, schema_editor):
CustomLink = apps.get_model('extras', 'CustomLink')
CustomLink.objects.filter(button_class='outline-dark').update(button_class='default')
class Migration(migrations.Migration):
dependencies = [
('extras', '0115_convert_dashboard_widgets'),
]
operations = [
migrations.AlterField(
model_name='customlink',
name='button_class',
field=models.CharField(default='default', max_length=30),
),
migrations.RunPython(
code=update_link_buttons,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -28,7 +28,7 @@ def update_dashboard_widgets(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('extras', '0115_convert_dashboard_widgets'),
('extras', '0116_custom_link_button_color'),
('core', '0011_move_objectchange'),
]

View File

@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0116_move_objectchange'),
('extras', '0117_move_objectchange'),
]
operations = [

View File

@ -7,7 +7,7 @@ class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0117_customfield_uniqueness'),
('extras', '0118_customfield_uniqueness'),
('users', '0009_update_group_perms'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]

View File

@ -28,7 +28,7 @@ def set_event_types(apps, schema_editor):
class Migration(migrations.Migration):
dependencies = [
('extras', '0118_notifications'),
('extras', '0119_notifications'),
]
operations = [

View File

@ -1,12 +1,10 @@
# Generated by Django 5.0.7 on 2024-07-26 01:49
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('extras', '0119_eventrule_event_types'),
('extras', '0120_eventrule_event_types'),
]
operations = [

View File

@ -4,6 +4,7 @@ from django.utils.safestring import mark_safe
from core.models import ObjectType
from extras.models import CustomLink
from netbox.choices import ButtonColorChoices
register = template.Library()
@ -59,10 +60,11 @@ def custom_links(context, obj):
# Add non-grouped links
else:
button_class = 'outline-secondary' if cl.button_class == ButtonColorChoices.DEFAULT else cl.button_class
try:
if rendered := cl.render(link_context):
template_code += LINK_BUTTON.format(
rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
rendered['link'], rendered['link_target'], button_class, rendered['text']
)
except Exception as e:
template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" title="{e}">' \

View File

@ -1326,6 +1326,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
# If this is an HTMX request, return only the result HTML
if htmx_partial(request):
if request.GET.get('log'):
# If log=True, render only the log table
return render(request, 'htmx/table.html', context)
response = render(request, 'extras/htmx/script_result.html', context)
if job.completed or not job.started:
response.status_code = 286

View File

@ -1,10 +1,9 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import IntegerRangeField, SimpleArrayField
from django.core.exceptions import ValidationError
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.utils.translation import gettext_lazy as _
from dcim.models import Device, Interface, Location, Rack, Region, Site, SiteGroup
from dcim.models import Device, Interface, Site
from ipam.choices import *
from ipam.constants import *
from ipam.formfields import IPNetworkFormField
@ -18,8 +17,10 @@ from utilities.forms.fields import (
NumericRangeArrayField, SlugField
)
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.widgets import DatePicker
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface
from utilities.forms.utils import get_field_value
from utilities.forms.widgets import DatePicker, HTMXSelect
from utilities.templatetags.builtins.filters import bettertitle
from virtualization.models import VirtualMachine, VMInterface
__all__ = (
'AggregateForm',
@ -563,94 +564,34 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
class VLANGroupForm(NetBoxModelForm):
scope_type = ContentTypeChoiceField(
label=_('Scope type'),
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
required=False
)
region = DynamicModelChoiceField(
label=_('Region'),
queryset=Region.objects.all(),
required=False,
initial_params={
'sites': '$site'
}
)
sitegroup = DynamicModelChoiceField(
queryset=SiteGroup.objects.all(),
required=False,
initial_params={
'sites': '$site'
},
label=_('Site group')
)
site = DynamicModelChoiceField(
label=_('Site'),
queryset=Site.objects.all(),
required=False,
initial_params={
'locations': '$location'
},
query_params={
'region_id': '$region',
'group_id': '$sitegroup',
}
)
location = DynamicModelChoiceField(
label=_('Location'),
queryset=Location.objects.all(),
required=False,
initial_params={
'racks': '$rack'
},
query_params={
'site_id': '$site',
}
)
rack = DynamicModelChoiceField(
label=_('Rack'),
queryset=Rack.objects.all(),
required=False,
query_params={
'site_id': '$site',
'location_id': '$location',
}
)
clustergroup = DynamicModelChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
initial_params={
'clusters': '$cluster'
},
label=_('Cluster group')
)
cluster = DynamicModelChoiceField(
label=_('Cluster'),
queryset=Cluster.objects.all(),
required=False,
query_params={
'group_id': '$clustergroup',
}
)
slug = SlugField()
vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs')
)
scope_type = ContentTypeChoiceField(
queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
widget=HTMXSelect(),
required=False,
label=_('Scope type')
)
scope = DynamicModelChoiceField(
label=_('Scope'),
queryset=Site.objects.none(), # Initial queryset
required=False,
disabled=True,
selector=True
)
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
name=_('Scope')
),
FieldSet('scope_type', 'scope', name=_('Scope')),
)
class Meta:
model = VLANGroup
fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'vid_ranges', 'tags',
'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'scope', 'tags',
]
def __init__(self, *args, **kwargs):
@ -658,21 +599,30 @@ class VLANGroupForm(NetBoxModelForm):
initial = kwargs.get('initial', {})
if instance is not None and instance.scope:
initial[instance.scope_type.model] = instance.scope
initial['scope'] = instance.scope
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
if scope_type_id := get_field_value(self, 'scope_type'):
try:
scope_type = ContentType.objects.get(pk=scope_type_id)
model = scope_type.model_class()
self.fields['scope'].queryset = model.objects.all()
self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower
self.fields['scope'].disabled = False
self.fields['scope'].label = _(bettertitle(model._meta.verbose_name))
except ObjectDoesNotExist:
pass
if self.instance and scope_type_id != self.instance.scope_type_id:
self.initial['scope'] = None
def clean(self):
super().clean()
# Assign scope based on scope_type
if self.cleaned_data.get('scope_type'):
scope_field = self.cleaned_data['scope_type'].model
self.instance.scope = self.cleaned_data.get(scope_field)
else:
self.instance.scope_id = None
# Assign the selected scope (if any)
self.instance.scope = self.cleaned_data.get('scope')
class VLANForm(TenancyForm, NetBoxModelForm):

View File

@ -3,88 +3,55 @@ from typing import List
import strawberry
import strawberry_django
from ipam import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class IPAMQuery:
@strawberry.field
def asn(self, id: int) -> ASNType:
return models.ASN.objects.get(pk=id)
asn: ASNType = strawberry_django.field()
asn_list: List[ASNType] = strawberry_django.field()
@strawberry.field
def asn_range(self, id: int) -> ASNRangeType:
return models.ASNRange.objects.get(pk=id)
asn_range: ASNRangeType = strawberry_django.field()
asn_range_list: List[ASNRangeType] = strawberry_django.field()
@strawberry.field
def aggregate(self, id: int) -> AggregateType:
return models.Aggregate.objects.get(pk=id)
aggregate: AggregateType = strawberry_django.field()
aggregate_list: List[AggregateType] = strawberry_django.field()
@strawberry.field
def ip_address(self, id: int) -> IPAddressType:
return models.IPAddress.objects.get(pk=id)
ip_address: IPAddressType = strawberry_django.field()
ip_address_list: List[IPAddressType] = strawberry_django.field()
@strawberry.field
def ip_range(self, id: int) -> IPRangeType:
return models.IPRange.objects.get(pk=id)
ip_range: IPRangeType = strawberry_django.field()
ip_range_list: List[IPRangeType] = strawberry_django.field()
@strawberry.field
def prefix(self, id: int) -> PrefixType:
return models.Prefix.objects.get(pk=id)
prefix: PrefixType = strawberry_django.field()
prefix_list: List[PrefixType] = strawberry_django.field()
@strawberry.field
def rir(self, id: int) -> RIRType:
return models.RIR.objects.get(pk=id)
rir: RIRType = strawberry_django.field()
rir_list: List[RIRType] = strawberry_django.field()
@strawberry.field
def role(self, id: int) -> RoleType:
return models.Role.objects.get(pk=id)
role: RoleType = strawberry_django.field()
role_list: List[RoleType] = strawberry_django.field()
@strawberry.field
def route_target(self, id: int) -> RouteTargetType:
return models.RouteTarget.objects.get(pk=id)
route_target: RouteTargetType = strawberry_django.field()
route_target_list: List[RouteTargetType] = strawberry_django.field()
@strawberry.field
def service(self, id: int) -> ServiceType:
return models.Service.objects.get(pk=id)
service: ServiceType = strawberry_django.field()
service_list: List[ServiceType] = strawberry_django.field()
@strawberry.field
def service_template(self, id: int) -> ServiceTemplateType:
return models.ServiceTemplate.objects.get(pk=id)
service_template: ServiceTemplateType = strawberry_django.field()
service_template_list: List[ServiceTemplateType] = strawberry_django.field()
@strawberry.field
def fhrp_group(self, id: int) -> FHRPGroupType:
return models.FHRPGroup.objects.get(pk=id)
fhrp_group: FHRPGroupType = strawberry_django.field()
fhrp_group_list: List[FHRPGroupType] = strawberry_django.field()
@strawberry.field
def fhrp_group_assignment(self, id: int) -> FHRPGroupAssignmentType:
return models.FHRPGroupAssignment.objects.get(pk=id)
fhrp_group_assignment: FHRPGroupAssignmentType = strawberry_django.field()
fhrp_group_assignment_list: List[FHRPGroupAssignmentType] = strawberry_django.field()
@strawberry.field
def vlan(self, id: int) -> VLANType:
return models.VLAN.objects.get(pk=id)
vlan: VLANType = strawberry_django.field()
vlan_list: List[VLANType] = strawberry_django.field()
@strawberry.field
def vlan_group(self, id: int) -> VLANGroupType:
return models.VLANGroup.objects.get(pk=id)
vlan_group: VLANGroupType = strawberry_django.field()
vlan_group_list: List[VLANGroupType] = strawberry_django.field()
@strawberry.field
def vrf(self, id: int) -> VRFType:
return models.VRF.objects.get(pk=id)
vrf: VRFType = strawberry_django.field()
vrf_list: List[VRFType] = strawberry_django.field()

View File

@ -81,10 +81,7 @@ class ColorChoices(ChoiceSet):
#
class ButtonColorChoices(ChoiceSet):
"""
Map standard button color choices to Bootstrap 3 button classes
"""
DEFAULT = 'outline-dark'
DEFAULT = 'default'
BLUE = 'blue'
INDIGO = 'indigo'
PURPLE = 'purple'

View File

@ -60,6 +60,8 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms
if value in self.fields[cf_name].empty_values:
self.instance.custom_field_data[key] = None
else:
if customfield.type == CustomFieldTypeChoices.TYPE_JSON and type(value) is str:
value = json.loads(value)
self.instance.custom_field_data[key] = customfield.serialize(value)
return super().clean()

View File

@ -4,7 +4,7 @@ from typing import List
import django_filters
import strawberry
import strawberry_django
from django.core.exceptions import FieldDoesNotExist
from django.core.exceptions import FieldDoesNotExist, ValidationError
from strawberry import auto
from ipam.fields import ASNField
from netbox.graphql.scalars import BigInt
@ -201,4 +201,9 @@ def autotype_decorator(filterset):
class BaseFilterMixin:
def filter_by_filterset(self, queryset, key):
return self.filterset(data={key: getattr(self, key)}, queryset=queryset).qs
filterset = self.filterset(data={key: getattr(self, key)}, queryset=queryset)
if not filterset.is_valid():
# We could raise validation error but strawberry logs it all to the
# console i.e. raise ValidationError(f"{k}: {v[0]}")
return filterset.qs.none()
return filterset.qs

View File

@ -767,6 +767,7 @@ LOCALE_PATHS = (
# Strawberry (GraphQL)
#
STRAWBERRY_DJANGO = {
"DEFAULT_PK_FIELD_NAME": "id",
"TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True,
"USE_DEPRECATED_FILTERS": True,
}

View File

@ -331,15 +331,22 @@ class ActionsColumn(tables.Column):
class ChoiceFieldColumn(tables.Column):
"""
Render a model's static ChoiceField with its value from `get_FOO_display()` as a colored badge. Background color is
set by the instance's get_FOO_color() method, if defined.
set by the instance's get_FOO_color() method, if defined, or can be overridden by a "color" callable.
"""
DEFAULT_BG_COLOR = 'secondary'
def __init__(self, *args, color=None, **kwargs):
super().__init__(*args, **kwargs)
self.color = color
def render(self, record, bound_column, value):
if value in self.empty_values:
return self.default
# Determine the background color to use (try calling object.get_FOO_color())
# Determine the background color to use (use "color" callable if given, else try calling object.get_FOO_color())
if self.color:
bg_color = self.color(record)
else:
try:
bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
except AttributeError:

View File

@ -13,11 +13,9 @@ class DummyModelType:
pass
@strawberry.type
@strawberry.type(name="Query")
class DummyQuery:
@strawberry.field
def dummymodel(self, id: int) -> DummyModelType:
return None
dummymodel: DummyModelType = strawberry_django.field()
dummymodel_list: List[DummyModelType] = strawberry_django.field()

View File

@ -1,7 +1,13 @@
import json
from django.test import override_settings
from django.urls import reverse
from rest_framework import status
from utilities.testing import disable_warnings, TestCase
from core.models import ObjectType
from dcim.models import Site, Location
from users.models import ObjectPermission
from utilities.testing import disable_warnings, APITestCase, TestCase
class GraphQLTestCase(TestCase):
@ -34,3 +40,45 @@ class GraphQLTestCase(TestCase):
response = self.client.get(url, **header)
with disable_warnings('django.request'):
self.assertHttpStatus(response, 302) # Redirect to login page
class GraphQLAPITestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_filter_objects(self):
"""
Test the operation of filters for GraphQL API requests.
"""
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
)
Site.objects.bulk_create(sites)
Location.objects.create(site=sites[0], name='Location 1', slug='location-1'),
Location.objects.create(site=sites[1], name='Location 2', slug='location-2'),
# Add object-level permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
# A valid request should return the filtered list
url = reverse('graphql')
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
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']['location_list']), 1)
# An invalid request should return an empty list
query = '{location_list(filters: {site_id: "99999"}) {id site {id}}}' # Invalid site ID
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.assertEqual(len(data['data']['location_list']), 0)

View File

@ -16,6 +16,7 @@ from django.urls import reverse
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from django_tables2.export import TableExport
from mptt.models import MPTTModel
from core.models import ObjectType
from core.signals import clear_events
@ -178,6 +179,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
table.columns.hide('pk')
return render(request, 'htmx/table.html', {
'table': table,
'model': model,
'actions': actions,
})
context = {
@ -612,6 +615,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
if form.cleaned_data.get('remove_tags', None):
obj.tags.remove(*form.cleaned_data['remove_tags'])
# Rebuild the tree for MPTT models
if issubclass(self.queryset.model, MPTTModel):
self.queryset.model.objects.rebuild()
return updated_objects
#

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -39,10 +39,17 @@ export function initFormElements(): void {
// Find each of the form's submitters. Most object edit forms have a "Create" and
// a "Create & Add", so we need to add a listener to both.
const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]');
for (const submitter of submitters) {
// Add the event listener to each submitter.
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form));
}
// Initialize any reset buttons so that when clicked, the page is reloaded without query parameters.
const resetButton = document.querySelector<HTMLButtonElement>('button[data-reset-select]');
if (resetButton !== null) {
resetButton.addEventListener('click', () => {
window.location.assign(window.location.origin + window.location.pathname);
});
}
}
}

View File

@ -1,9 +1,8 @@
import { initFormElements } from './elements';
import { initSpeedSelector } from './speedSelector';
import { initScopeSelector } from './scopeSelector';
export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initScopeSelector]) {
for (const func of [initFormElements, initSpeedSelector]) {
func();
}
}

View File

@ -1,153 +0,0 @@
import { getElements, toggleVisibility } from '../util';
type ShowHideMap = {
/**
* Name of view to which this map should apply.
*
* @example vlangroup_edit
*/
[view: string]: string;
};
type ShowHideLayout = {
/**
* Name of layout config
*
* @example vlangroup
*/
[config: string]: {
/**
* Default layout.
*/
default: { hide: string[]; show: string[] };
/**
* Field name to layout mapping.
*/
[fieldName: string]: { hide: string[]; show: string[] };
};
};
/**
* Mapping of layout names to arrays of object types whose fields should be hidden or shown when
* the scope type (key) is selected.
*
* For example, if `region` is the scope type, the fields with IDs listed in
* showHideMap.region.hide should be hidden, and the fields with IDs listed in
* showHideMap.region.show should be shown.
*/
const showHideLayout: ShowHideLayout = {
vlangroup: {
region: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region'],
},
'site group': {
hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_sitegroup'],
},
site: {
hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site'],
},
location: {
hide: ['id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
},
rack: {
hide: ['id_clustergroup', 'id_cluster'],
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
},
'cluster group': {
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'],
show: ['id_clustergroup'],
},
cluster: {
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
show: ['id_clustergroup', 'id_cluster'],
},
default: {
hide: [
'id_region',
'id_sitegroup',
'id_site',
'id_location',
'id_rack',
'id_clustergroup',
'id_cluster',
],
show: [],
},
},
};
/**
* Mapping of view names to layout configurations
*
* For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
*/
const showHideMap: ShowHideMap = {
vlangroup_add: 'vlangroup',
vlangroup_edit: 'vlangroup',
vlangroup_bulk_edit: 'vlangroup',
};
/**
* Toggle visibility of a given element's parent.
* @param query CSS Query.
* @param action Show or Hide the Parent.
*/
function toggleParentVisibility(query: string, action: 'show' | 'hide') {
for (const element of getElements(query)) {
const parent = element.parentElement?.parentElement as Nullable<HTMLDivElement>;
if (parent !== null) {
if (action === 'show') {
toggleVisibility(parent, 'show');
} else {
toggleVisibility(parent, 'hide');
}
}
}
}
/**
* Handle changes to the Scope Type field.
*/
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
// Scope type's innerText looks something like `DCIM > region`.
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
const layoutConfig = showHideMap[view];
for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
// If the scope type ends with the specified scope, toggle its field visibility according to
// the show/hide values.
if (scopeType.endsWith(scope)) {
for (const field of fields.hide) {
toggleParentVisibility(`#${field}`, 'hide');
}
for (const field of fields.show) {
toggleParentVisibility(`#${field}`, 'show');
}
// Stop on first match.
break;
} else {
// Otherwise, hide all fields.
for (const field of showHideLayout[layoutConfig].default.hide) {
toggleParentVisibility(`#${field}`, 'hide');
}
}
}
}
/**
* Initialize scope type select event listeners.
*/
export function initScopeSelector(): void {
for (const view of Object.keys(showHideMap)) {
for (const element of getElements<HTMLSelectElement>(
`html[data-netbox-url-name="${view}"] #id_scope_type`,
)) {
handleScopeChange(view, element);
element.addEventListener('change', () => handleScopeChange(view, element));
}
}
}

View File

@ -30,6 +30,9 @@
// Remove the bottom margin of <p> elements inside a table cell
td > .rendered-markdown {
max-height: 200px;
overflow-y: scroll;
p:last-of-type {
margin-bottom: 0;
}

View File

@ -25,3 +25,14 @@ hr.dropdown-divider {
.dropdown-item {
font-weight: $font-weight-base;
}
// Restore support for old Bootstrap v3 colors
.text-bg-black {
@extend .text-bg-dark;
}
.text-bg-gray {
@extend .text-bg-secondary;
}
.text-bg-white {
@extend .text-bg-light;
}

View File

@ -130,6 +130,19 @@ body[data-bs-theme=dark] {
}
}
// Do not apply padding to <code> elements inside a <pre>
pre code {
padding: unset;
}
// Use an icon instead of Tabler's native "caret" for dropdowns (avoids a Safari bug)
.dropdown-toggle:after{
font-family: "Material Design Icons";
content: '\F0140';
padding-right: 9px;
border-bottom: none;
border-left: none;
transform: none;
vertical-align: .05em;
height: auto;
}

View File

@ -95,7 +95,7 @@
<tr>
<th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
{% if config.CUSTOM_VALIDATORS %}
<td><pre>{{ config.CUSTOM_VALIDATORS|json }}</pre></td>
<td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
{% else %}
<td>{{ ''|placeholder }}</td>
{% endif %}

View File

@ -10,15 +10,17 @@
gs-id="{{ widget.id }}"
>
<div class="card grid-stack-item-content">
<div class="card-header {% if widget.color %} text-{{ widget.fg_color }} {% endif %} bg-{{ widget.color|default:'default' }} px-2 py-1 d-flex flex-row">
{% with bg_color=widget.color|default:"secondary" %}
<div class="card-header text-bg-{{ bg_color }} px-2 py-1 d-flex flex-row">
<a href="#"
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
hx-target="#htmx-modal-content"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
class="text-bg-{{ bg_color }}"
aria-label="{{ widget.title }} {% trans "widget configuration" %}"
>
<i class="mdi mdi-cog {% if widget.color %} text-{{ widget.fg_color }} {% endif %}"></i>
<i class="mdi mdi-cog"></i>
</a>
<div class="card-title flex-fill text-center">
{% if widget.title %}
@ -30,11 +32,13 @@
hx-target="#htmx-modal-content"
data-bs-toggle="modal"
data-bs-target="#htmx-modal"
class="text-bg-{{ bg_color }}"
aria-label="{% trans "Close widget" %} {{ widget.title }}"
>
<i class="mdi mdi-close {% if widget.color %} text-{{ widget.fg_color }} {% endif %}"></i>
<i class="mdi mdi-close"></i>
</a>
</div>
{% endwith %}
<div class="card-body p-2 pt-1 overflow-auto">
{% render_widget widget %}
</div>

View File

@ -41,7 +41,11 @@
<div class="card">
<div class="table-responsive" id="object_list">
<h2 class="card-header">{% trans "Log" %}</h2>
{% include 'htmx/table.html' %}
<div class="htmx-container table-responsive"
hx-get="{% url 'extras:script_result' job_pk=job.pk %}?embedded=True&log=True"
hx-target="this"
hx-trigger="load" hx-select=".htmx-container" hx-swap="outerHTML"
></div>
</div>
</div>
{% endif %}

View File

@ -108,14 +108,6 @@
</div>
{# /Object list tab #}
{# Filters tab #}
{% if filter_form %}
<div class="tab-pane show" id="filters-form" role="tabpanel" aria-labelledby="filters-form-tab">
{% include 'inc/filter_list.html' %}
</div>
{% endif %}
{# /Filters tab #}
{% endblock content %}
{% block modals %}

View File

@ -81,15 +81,7 @@ Context:
{% if table.paginator.num_pages > 1 %}
<div id="select-all-box" class="d-none card d-print-none">
<div class="form col-md-12">
<div class="card-body">
<div class="float-end">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
<div class="card-body d-flex justify-content-between">
<div class="form-check">
<input type="checkbox" id="select-all" name="_all" class="form-check-input" />
<label for="select-all" class="form-check-label">
@ -98,6 +90,14 @@ Context:
{% endblocktrans %}
</label>
</div>
<div class="bulk-action-buttons">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
</div>
</div>
</div>
@ -123,12 +123,14 @@ Context:
{# Form buttons #}
<div class="btn-list d-print-none mt-2">
{% block bulk_buttons %}
<div class="bulk-action-buttons">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
{% endblock %}
</div>
{# /Form buttons #}

View File

@ -1,5 +1,6 @@
{# Render an HTMX-enabled table with paginator #}
{% load helpers %}
{% load buttons %}
{% load render_table from django_tables2 %}
<div class="htmx-container table-responsive">
@ -14,5 +15,19 @@
{% endwith %}
</div>
{# Include the updated object count for display elsewhere on the page #}
<div class="d-none" hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>
{% if request.htmx %}
{# Include the updated object count for display elsewhere on the page #}
<div hx-swap-oob="innerHTML:.total-object-count">{{ table.rows|length }}</div>
{# Update the bulk action buttons with new query parameters #}
{% if actions %}
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div>
{% endif %}
{% endif %}

View File

@ -5,7 +5,8 @@
<h2 class="card-header">{% trans "Related Objects" %}</h2>
<ul class="list-group list-group-flush" role="presentation">
{% for qs, filter_param in related_models %}
{% with viewname=qs.model|viewname:"list" %}
{% with viewname=qs.model|validated_viewname:"list" %}
{% if viewname is not None %}
<a href="{% url viewname %}?{{ filter_param }}={{ object.pk }}" class="list-group-item list-group-item-action d-flex justify-content-between">
{{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %}
@ -16,6 +17,7 @@
{% endif %}
{% endwith %}
</a>
{% endif %}
{% endwith %}
{% endfor %}
</ul>

View File

@ -81,7 +81,7 @@
<div class="col">
<a href="{{ backend.url }}" class="btn w-100">
{% if backend.icon_name %}<i class="mdi mdi-{{ backend.icon_name }}"></i>
{% elif backend.icon_img %}<img src="{{ backend.icon_img }}" height="24" class="me-2" />{% endif %}
{% elif backend.icon_img %}<img src="{{ backend.icon_img }}" height="24" {% if backend.display_name %}class="me-2" {% endif %}/>{% endif %}
{{ backend.display_name }}
</a>
</div>

View File

@ -3,38 +3,25 @@ from typing import List
import strawberry
import strawberry_django
from tenancy import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class TenancyQuery:
@strawberry.field
def tenant(self, id: int) -> TenantType:
return models.Tenant.objects.get(pk=id)
tenant: TenantType = strawberry_django.field()
tenant_list: List[TenantType] = strawberry_django.field()
@strawberry.field
def tenant_group(self, id: int) -> TenantGroupType:
return models.TenantGroup.objects.get(pk=id)
tenant_group: TenantGroupType = strawberry_django.field()
tenant_group_list: List[TenantGroupType] = strawberry_django.field()
@strawberry.field
def contact(self, id: int) -> ContactType:
return models.Contact.objects.get(pk=id)
contact: ContactType = strawberry_django.field()
contact_list: List[ContactType] = strawberry_django.field()
@strawberry.field
def contact_role(self, id: int) -> ContactRoleType:
return models.ContactRole.objects.get(pk=id)
contact_role: ContactRoleType = strawberry_django.field()
contact_role_list: List[ContactRoleType] = strawberry_django.field()
@strawberry.field
def contact_group(self, id: int) -> ContactGroupType:
return models.ContactGroup.objects.get(pk=id)
contact_group: ContactGroupType = strawberry_django.field()
contact_group_list: List[ContactGroupType] = strawberry_django.field()
@strawberry.field
def contact_assignment(self, id: int) -> ContactAssignmentType:
return models.ContactAssignment.objects.get(pk=id)
contact_assignment: ContactAssignmentType = strawberry_django.field()
contact_assignment_list: List[ContactAssignmentType] = strawberry_django.field()

View File

@ -113,11 +113,12 @@ class ContactAssignmentTable(NetBoxTable):
)
contact_phone = tables.Column(
accessor=Accessor('contact__phone'),
verbose_name=_('Contact Phone')
verbose_name=_('Contact Phone'),
linkify=linkify_phone,
)
contact_email = tables.Column(
contact_email = tables.EmailColumn(
accessor=Accessor('contact__email'),
verbose_name=_('Contact Email')
verbose_name=_('Contact Email'),
)
contact_address = tables.Column(
accessor=Accessor('contact__address'),

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -39,7 +39,7 @@ class TokenSerializer(ValidatedModelSerializer):
brief_fields = ('id', 'url', 'display', 'key', 'write_enabled', 'description')
def to_internal_value(self, data):
if 'key' not in data:
if not getattr(self.instance, 'key', None) and 'key' not in data:
data['key'] = Token.generate_key()
return super().to_internal_value(data)

View File

@ -3,18 +3,13 @@ from typing import List
import strawberry
import strawberry_django
from users import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class UsersQuery:
@strawberry.field
def group(self, id: int) -> GroupType:
return models.Group.objects.get(pk=id)
group: GroupType = strawberry_django.field()
group_list: List[GroupType] = strawberry_django.field()
@strawberry.field
def user(self, id: int) -> UserType:
return models.User.objects.get(pk=id)
user: UserType = strawberry_django.field()
user_list: List[UserType] = strawberry_django.field()

View File

@ -3,6 +3,7 @@ import decimal
from django.core.serializers.json import DjangoJSONEncoder
__all__ = (
'ConfigJSONEncoder',
'CustomFieldJSONEncoder',
)
@ -15,3 +16,16 @@ class CustomFieldJSONEncoder(DjangoJSONEncoder):
if isinstance(o, decimal.Decimal):
return float(o)
return super().default(o)
class ConfigJSONEncoder(DjangoJSONEncoder):
"""
Override Django's built-in JSON encoder to serialize CustomValidator classes as strings.
"""
def default(self, o):
from extras.validators import CustomValidator
if issubclass(type(o), CustomValidator):
return type(o).__name__
return super().default(o)

View File

@ -1,7 +1,10 @@
from django.conf import settings
from django.apps import apps
from django.db.models import Q
from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
__all__ = (
'get_permission_for_model',
'permission_is_exempt',
@ -90,6 +93,11 @@ def qs_filter_from_constraints(constraints, tokens=None):
if tokens is None:
tokens = {}
User = apps.get_model('users.User')
for token, value in tokens.items():
if token == CONSTRAINT_TOKEN_USER and isinstance(value, User):
tokens[token] = value.id
def _replace_tokens(value, tokens):
if type(value) is list:
return list(map(lambda v: tokens.get(v, v), value))

View File

@ -8,6 +8,7 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.humanize.templatetags.humanize import naturalday, naturaltime
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from markdown import markdown
from markdown.extensions.tables import TableExtension
@ -218,7 +219,8 @@ def isodate(value):
text = value.isoformat()
return mark_safe(f'<span title="{naturalday(value)}">{text}</span>')
elif type(value) is datetime.datetime:
text = value.date().isoformat()
local_value = localtime(value) if value.tzinfo else value
text = local_value.date().isoformat()
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
else:
return ''
@ -229,7 +231,8 @@ def isotime(value, spec='seconds'):
if type(value) is datetime.time:
return value.isoformat(timespec=spec)
if type(value) is datetime.datetime:
return value.time().isoformat(timespec=spec)
local_value = localtime(value) if value.tzinfo else value
return local_value.time().isoformat(timespec=spec)
return ''

View File

@ -18,7 +18,7 @@ from ipam.graphql.types import IPAddressFamilyType
from users.models import ObjectPermission, Token, User
from utilities.api import get_graphql_type_for_model
from .base import ModelTestCase
from .utils import disable_warnings
from .utils import disable_logging, disable_warnings
__all__ = (
'APITestCase',
@ -517,7 +517,6 @@ class APIViewTestCases:
return self._build_query_with_filter(name, filter_string)
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_get_object(self):
url = reverse('graphql')
field_name = self._get_graphql_base_name()
@ -525,57 +524,85 @@ class APIViewTestCases:
query = self._build_query(field_name, id=object_id)
# Non-authenticated requests should fail
with disable_warnings('django.request'):
header = {
'HTTP_ACCEPT': 'application/json',
}
self.assertHttpStatus(self.client.post(url, data={'query': query}, format="json", **header), status.HTTP_403_FORBIDDEN)
with disable_warnings('django.request'):
response = self.client.post(url, data={'query': query}, format="json", **header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Add object-level permission
# Add constrained permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
actions=['view'],
constraints={'id': 0} # Impossible constraint
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Request should succeed but return empty result
with disable_logging():
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.assertIn('errors', data)
self.assertIsNone(data['data'])
# Remove permission constraint
obj_perm.constraints = None
obj_perm.save()
# Request should return requested object
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.assertIsNotNone(data['data'])
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_list_objects(self):
url = reverse('graphql')
field_name = f'{self._get_graphql_base_name()}_list'
query = self._build_query(field_name)
# Non-authenticated requests should fail
with disable_warnings('django.request'):
header = {
'HTTP_ACCEPT': 'application/json',
}
self.assertHttpStatus(self.client.post(url, data={'query': query}, format="json", **header), status.HTTP_403_FORBIDDEN)
with disable_warnings('django.request'):
response = self.client.post(url, data={'query': query}, format="json", **header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Add object-level permission
# Add constrained permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view']
actions=['view'],
constraints={'id': 0} # Impossible constraint
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Request should succeed but return empty results list
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.assertGreater(len(data['data'][field_name]), 0)
self.assertEqual(len(data['data'][field_name]), 0)
# Remove permission constraint
obj_perm.constraints = None
obj_perm.save()
# Request should return all objects
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'][field_name]), self.model.objects.count())
@override_settings(LOGIN_REQUIRED=True)
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*', 'auth.user'])
def test_graphql_filter_objects(self):
if not hasattr(self, 'graphql_filter'):
return

View File

@ -107,6 +107,16 @@ def disable_warnings(logger_name):
logger.setLevel(current_level)
@contextmanager
def disable_logging(level=logging.CRITICAL):
"""
Temporarily suppress log messages at or below the specified level (default: critical).
"""
logging.disable(level)
yield
logging.disable(logging.NOTSET)
#
# Custom field testing
#

View File

@ -3,38 +3,25 @@ from typing import List
import strawberry
import strawberry_django
from virtualization import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class VirtualizationQuery:
@strawberry.field
def cluster(self, id: int) -> ClusterType:
return models.Cluster.objects.get(pk=id)
cluster: ClusterType = strawberry_django.field()
cluster_list: List[ClusterType] = strawberry_django.field()
@strawberry.field
def cluster_group(self, id: int) -> ClusterGroupType:
return models.ClusterGroup.objects.get(pk=id)
cluster_group: ClusterGroupType = strawberry_django.field()
cluster_group_list: List[ClusterGroupType] = strawberry_django.field()
@strawberry.field
def cluster_type(self, id: int) -> ClusterTypeType:
return models.ClusterType.objects.get(pk=id)
cluster_type: ClusterTypeType = strawberry_django.field()
cluster_type_list: List[ClusterTypeType] = strawberry_django.field()
@strawberry.field
def virtual_machine(self, id: int) -> VirtualMachineType:
return models.VirtualMachine.objects.get(pk=id)
virtual_machine: VirtualMachineType = strawberry_django.field()
virtual_machine_list: List[VirtualMachineType] = strawberry_django.field()
@strawberry.field
def vm_interface(self, id: int) -> VMInterfaceType:
return models.VMInterface.objects.get(pk=id)
vm_interface: VMInterfaceType = strawberry_django.field()
vm_interface_list: List[VMInterfaceType] = strawberry_django.field()
@strawberry.field
def virtual_disk(self, id: int) -> VirtualDiskType:
return models.VirtualDisk.objects.get(pk=id)
virtual_disk: VirtualDiskType = strawberry_django.field()
virtual_disk_list: List[VirtualDiskType] = strawberry_django.field()

View File

@ -3,58 +3,37 @@ from typing import List
import strawberry
import strawberry_django
from vpn import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class VPNQuery:
@strawberry.field
def ike_policy(self, id: int) -> IKEPolicyType:
return models.IKEPolicy.objects.get(pk=id)
ike_policy: IKEPolicyType = strawberry_django.field()
ike_policy_list: List[IKEPolicyType] = strawberry_django.field()
@strawberry.field
def ike_proposal(self, id: int) -> IKEProposalType:
return models.IKEProposal.objects.get(pk=id)
ike_proposal: IKEProposalType = strawberry_django.field()
ike_proposal_list: List[IKEProposalType] = strawberry_django.field()
@strawberry.field
def ipsec_policy(self, id: int) -> IPSecPolicyType:
return models.IPSecPolicy.objects.get(pk=id)
ipsec_policy: IPSecPolicyType = strawberry_django.field()
ipsec_policy_list: List[IPSecPolicyType] = strawberry_django.field()
@strawberry.field
def ipsec_profile(self, id: int) -> IPSecProfileType:
return models.IPSecProfile.objects.get(pk=id)
ipsec_profile: IPSecProfileType = strawberry_django.field()
ipsec_profile_list: List[IPSecProfileType] = strawberry_django.field()
@strawberry.field
def ipsec_proposal(self, id: int) -> IPSecProposalType:
return models.IPSecProposal.objects.get(pk=id)
ipsec_proposal: IPSecProposalType = strawberry_django.field()
ipsec_proposal_list: List[IPSecProposalType] = strawberry_django.field()
@strawberry.field
def l2vpn(self, id: int) -> L2VPNType:
return models.L2VPN.objects.get(pk=id)
l2vpn: L2VPNType = strawberry_django.field()
l2vpn_list: List[L2VPNType] = strawberry_django.field()
@strawberry.field
def l2vpn_termination(self, id: int) -> L2VPNTerminationType:
return models.L2VPNTermination.objects.get(pk=id)
l2vpn_termination: L2VPNTerminationType = strawberry_django.field()
l2vpn_termination_list: List[L2VPNTerminationType] = strawberry_django.field()
@strawberry.field
def tunnel(self, id: int) -> TunnelType:
return models.Tunnel.objects.get(pk=id)
tunnel: TunnelType = strawberry_django.field()
tunnel_list: List[TunnelType] = strawberry_django.field()
@strawberry.field
def tunnel_group(self, id: int) -> TunnelGroupType:
return models.TunnelGroup.objects.get(pk=id)
tunnel_group: TunnelGroupType = strawberry_django.field()
tunnel_group_list: List[TunnelGroupType] = strawberry_django.field()
@strawberry.field
def tunnel_termination(self, id: int) -> TunnelTerminationType:
return models.TunnelTermination.objects.get(pk=id)
tunnel_termination: TunnelTerminationType = strawberry_django.field()
tunnel_termination_list: List[TunnelTerminationType] = strawberry_django.field()

View File

@ -3,23 +3,16 @@ from typing import List
import strawberry
import strawberry_django
from wireless import models
from .types import *
@strawberry.type
@strawberry.type(name="Query")
class WirelessQuery:
@strawberry.field
def wireless_lan(self, id: int) -> WirelessLANType:
return models.WirelessLAN.objects.get(pk=id)
wireless_lan: WirelessLANType = strawberry_django.field()
wireless_lan_list: List[WirelessLANType] = strawberry_django.field()
@strawberry.field
def wireless_lan_group(self, id: int) -> WirelessLANGroupType:
return models.WirelessLANGroup.objects.get(pk=id)
wireless_lan_group: WirelessLANGroupType = strawberry_django.field()
wireless_lan_group_list: List[WirelessLANGroupType] = strawberry_django.field()
@strawberry.field
def wireless_link(self, id: int) -> WirelessLinkType:
return models.WirelessLink.objects.get(pk=id)
wireless_link: WirelessLinkType = strawberry_django.field()
wireless_link_list: List[WirelessLinkType] = strawberry_django.field()

View File

@ -19,8 +19,8 @@ drf-spectacular-sidecar==2024.7.1
feedparser==6.0.11
gunicorn==23.0.0
Jinja2==3.1.4
Markdown==3.6
mkdocs-material==9.5.31
Markdown==3.7
mkdocs-material==9.5.33
mkdocstrings[python-legacy]==0.25.2
netaddr==1.3.0
nh3==0.2.18