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: attributes:
label: NetBox Version label: NetBox Version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.0.9 placeholder: v4.0.10
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v4.0.9 placeholder: v4.0.10
validations: validations:
required: true required: true
- type: dropdown - 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 Default: False
This setting enables debugging. Debugging should be enabled only during development or troubleshooting. Note that only 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. interface.
!!! warning !!! 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 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 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 ## 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: 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 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 ### Close the Release Milestone

View File

@ -41,7 +41,7 @@ Line breaks are permitted following binary operators.
### Enforcing Code Style ### 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/ pycodestyle --ignore=W504,E501 netbox/

View File

@ -1,6 +1,38 @@
# NetBox v4.0 # 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
import strawberry_django import strawberry_django
from circuits import models
from .types import * from .types import *
@strawberry.type @strawberry.type(name="Query")
class CircuitsQuery: class CircuitsQuery:
@strawberry.field circuit: CircuitType = strawberry_django.field()
def circuit(self, id: int) -> CircuitType:
return models.Circuit.objects.get(pk=id)
circuit_list: List[CircuitType] = strawberry_django.field() circuit_list: List[CircuitType] = strawberry_django.field()
@strawberry.field circuit_termination: CircuitTerminationType = strawberry_django.field()
def circuit_termination(self, id: int) -> CircuitTerminationType:
return models.CircuitTermination.objects.get(pk=id)
circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field() circuit_termination_list: List[CircuitTerminationType] = strawberry_django.field()
@strawberry.field circuit_type: CircuitTypeType = strawberry_django.field()
def circuit_type(self, id: int) -> CircuitTypeType:
return models.CircuitType.objects.get(pk=id)
circuit_type_list: List[CircuitTypeType] = strawberry_django.field() circuit_type_list: List[CircuitTypeType] = strawberry_django.field()
@strawberry.field circuit_group: CircuitGroupType = strawberry_django.field()
def circuit_group(self, id: int) -> CircuitGroupType:
return models.CircuitGroup.objects.get(pk=id)
circuit_group_list: List[CircuitGroupType] = strawberry_django.field() circuit_group_list: List[CircuitGroupType] = strawberry_django.field()
@strawberry.field circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
def circuit_group_assignment(self, id: int) -> CircuitGroupAssignmentType:
return models.CircuitGroupAssignment.objects.get(pk=id)
circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field() circuit_group_assignment_list: List[CircuitGroupAssignmentType] = strawberry_django.field()
@strawberry.field provider: ProviderType = strawberry_django.field()
def provider(self, id: int) -> ProviderType:
return models.Provider.objects.get(pk=id)
provider_list: List[ProviderType] = strawberry_django.field() provider_list: List[ProviderType] = strawberry_django.field()
@strawberry.field provider_account: ProviderAccountType = strawberry_django.field()
def provider_account(self, id: int) -> ProviderAccountType:
return models.ProviderAccount.objects.get(pk=id)
provider_account_list: List[ProviderAccountType] = strawberry_django.field() provider_account_list: List[ProviderAccountType] = strawberry_django.field()
@strawberry.field provider_network: ProviderNetworkType = strawberry_django.field()
def provider_network(self, id: int) -> ProviderNetworkType:
return models.ProviderNetwork.objects.get(pk=id)
provider_network_list: List[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 django.db.models.deletion
import taggit.managers import taggit.managers
import utilities.json import utilities.json
@ -10,7 +8,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('circuits', '0043_circuittype_color'), ('circuits', '0043_circuittype_color'),
('extras', '0118_notifications'), ('extras', '0119_notifications'),
('tenancy', '0015_contactassignment_rename_content_type'), ('tenancy', '0015_contactassignment_rename_content_type'),
] ]

View File

@ -126,9 +126,18 @@ class NetBoxAutoSchema(AutoSchema):
return response_serializers 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): def get_serializer_ref_name(self, serializer):
# from drf-yasg.utils # 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 :param serializer: Serializer instance
:return: Serializer's ``ref_name`` or ``None`` for inline serializer :return: Serializer's ``ref_name`` or ``None`` for inline serializer
:rtype: str or None :rtype: str or None
@ -137,8 +146,6 @@ class NetBoxAutoSchema(AutoSchema):
serializer_name = type(serializer).__name__ serializer_name = type(serializer).__name__
if hasattr(serializer_meta, 'ref_name'): if hasattr(serializer_meta, 'ref_name'):
ref_name = serializer_meta.ref_name ref_name = serializer_meta.ref_name
elif serializer_name == 'NestedSerializer' and isinstance(serializer, serializers.ModelSerializer):
ref_name = None
else: else:
ref_name = serializer_name ref_name = serializer_name
if ref_name.endswith('Serializer'): if ref_name.endswith('Serializer'):

View File

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

View File

@ -2,7 +2,6 @@ import json
import platform import platform
from django import __version__ as DJANGO_VERSION from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin 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.data import shallow_compare_dict
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder
from utilities.query import count_related from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@ -643,10 +643,14 @@ class SystemView(UserPassesTestMixin, View):
k: getattr(config, k) for k in sorted(params) 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"' response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response 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', { return render(request, 'core/system.html', {
'stats': stats, 'stats': stats,
'config': config, 'config': config,

View File

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

View File

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

View File

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

View File

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

View File

@ -290,6 +290,11 @@ class DeviceComponentTable(NetBoxTable):
linkify=True, linkify=True,
order_by=('_name',) 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): class Meta(NetBoxTable.Meta):
order_by = ('device', 'name') order_by = ('device', 'name')

View File

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

View File

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

View File

@ -380,7 +380,9 @@ class SiteGroupContactsView(ObjectContactsView):
# #
class SiteListView(generic.ObjectListView): class SiteListView(generic.ObjectListView):
queryset = Site.objects.all() queryset = Site.objects.annotate(
device_count=count_related(Device, 'site')
)
filterset = filtersets.SiteFilterSet filterset = filtersets.SiteFilterSet
filterset_form = forms.SiteFilterForm filterset_form = forms.SiteFilterForm
table = tables.SiteTable table = tables.SiteTable
@ -3505,7 +3507,7 @@ class VirtualChassisAddMemberView(ObjectPermissionRequiredMixin, GetReturnURLMix
membership_form.save() membership_form.save()
messages.success(request, mark_safe( 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: if '_addanother' in request.POST:

View File

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

View File

@ -131,22 +131,6 @@ class DashboardWidget:
def name(self): def name(self):
return f'{self.__class__.__module__.split(".")[0]}.{self.__class__.__name__}' 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 @property
def form_data(self): def form_data(self):
return { return {

View File

@ -31,7 +31,7 @@ class ReportForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Annotate the current system time for reference # 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) self.fields['schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now)
# Remove scheduling fields if scheduling is disabled # Remove scheduling fields if scheduling is disabled

View File

@ -37,7 +37,7 @@ class ScriptForm(forms.Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
# Annotate the current system time for reference # 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) self.fields['_schedule_at'].help_text += _(' (current time: <strong>{now}</strong>)').format(now=now)
# Remove scheduling fields if scheduling is disabled # Remove scheduling fields if scheduling is disabled

View File

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

View File

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

View File

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

View File

@ -28,7 +28,7 @@ def set_event_types(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('extras', '0118_notifications'), ('extras', '0119_notifications'),
] ]
operations = [ 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 from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('extras', '0119_eventrule_event_types'), ('extras', '0120_eventrule_event_types'),
] ]
operations = [ operations = [

View File

@ -4,6 +4,7 @@ from django.utils.safestring import mark_safe
from core.models import ObjectType from core.models import ObjectType
from extras.models import CustomLink from extras.models import CustomLink
from netbox.choices import ButtonColorChoices
register = template.Library() register = template.Library()
@ -59,10 +60,11 @@ def custom_links(context, obj):
# Add non-grouped links # Add non-grouped links
else: else:
button_class = 'outline-secondary' if cl.button_class == ButtonColorChoices.DEFAULT else cl.button_class
try: try:
if rendered := cl.render(link_context): if rendered := cl.render(link_context):
template_code += LINK_BUTTON.format( 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: except Exception as e:
template_code += f'<a class="btn btn-sm btn-outline-secondary" disabled="disabled" title="{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 this is an HTMX request, return only the result HTML
if htmx_partial(request): 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) response = render(request, 'extras/htmx/script_result.html', context)
if job.completed or not job.started: if job.completed or not job.started:
response.status_code = 286 response.status_code = 286

View File

@ -1,10 +1,9 @@
from django import forms from django import forms
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import IntegerRangeField, SimpleArrayField from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ 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.choices import *
from ipam.constants import * from ipam.constants import *
from ipam.formfields import IPNetworkFormField from ipam.formfields import IPNetworkFormField
@ -18,8 +17,10 @@ from utilities.forms.fields import (
NumericRangeArrayField, SlugField NumericRangeArrayField, SlugField
) )
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.widgets import DatePicker from utilities.forms.utils import get_field_value
from virtualization.models import Cluster, ClusterGroup, VirtualMachine, VMInterface from utilities.forms.widgets import DatePicker, HTMXSelect
from utilities.templatetags.builtins.filters import bettertitle
from virtualization.models import VirtualMachine, VMInterface
__all__ = ( __all__ = (
'AggregateForm', 'AggregateForm',
@ -563,94 +564,34 @@ class FHRPGroupAssignmentForm(forms.ModelForm):
class VLANGroupForm(NetBoxModelForm): 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() slug = SlugField()
vid_ranges = NumericRangeArrayField( vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs') 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 = ( fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')), FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('vid_ranges', name=_('Child VLANs')), FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet( FieldSet('scope_type', 'scope', name=_('Scope')),
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
name=_('Scope')
),
) )
class Meta: class Meta:
model = VLANGroup model = VLANGroup
fields = [ fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'name', 'slug', 'description', 'vid_ranges', 'scope_type', 'scope', 'tags',
'clustergroup', 'cluster', 'vid_ranges', 'tags',
] ]
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@ -658,21 +599,30 @@ class VLANGroupForm(NetBoxModelForm):
initial = kwargs.get('initial', {}) initial = kwargs.get('initial', {})
if instance is not None and instance.scope: if instance is not None and instance.scope:
initial[instance.scope_type.model] = instance.scope initial['scope'] = instance.scope
kwargs['initial'] = initial kwargs['initial'] = initial
super().__init__(*args, **kwargs) 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): def clean(self):
super().clean() super().clean()
# Assign scope based on scope_type # Assign the selected scope (if any)
if self.cleaned_data.get('scope_type'): self.instance.scope = self.cleaned_data.get('scope')
scope_field = self.cleaned_data['scope_type'].model
self.instance.scope = self.cleaned_data.get(scope_field)
else:
self.instance.scope_id = None
class VLANForm(TenancyForm, NetBoxModelForm): class VLANForm(TenancyForm, NetBoxModelForm):

View File

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

View File

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

View File

@ -60,6 +60,8 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms
if value in self.fields[cf_name].empty_values: if value in self.fields[cf_name].empty_values:
self.instance.custom_field_data[key] = None self.instance.custom_field_data[key] = None
else: 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) self.instance.custom_field_data[key] = customfield.serialize(value)
return super().clean() return super().clean()

View File

@ -4,7 +4,7 @@ from typing import List
import django_filters import django_filters
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist, ValidationError
from strawberry import auto from strawberry import auto
from ipam.fields import ASNField from ipam.fields import ASNField
from netbox.graphql.scalars import BigInt from netbox.graphql.scalars import BigInt
@ -201,4 +201,9 @@ def autotype_decorator(filterset):
class BaseFilterMixin: class BaseFilterMixin:
def filter_by_filterset(self, queryset, key): 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 (GraphQL)
# #
STRAWBERRY_DJANGO = { STRAWBERRY_DJANGO = {
"DEFAULT_PK_FIELD_NAME": "id",
"TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True, "TYPE_DESCRIPTION_FROM_MODEL_DOCSTRING": True,
"USE_DEPRECATED_FILTERS": True, "USE_DEPRECATED_FILTERS": True,
} }

View File

@ -331,15 +331,22 @@ class ActionsColumn(tables.Column):
class ChoiceFieldColumn(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 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' 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): def render(self, record, bound_column, value):
if value in self.empty_values: if value in self.empty_values:
return self.default 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: try:
bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR bg_color = getattr(record, f'get_{bound_column.name}_color')() or self.DEFAULT_BG_COLOR
except AttributeError: except AttributeError:

View File

@ -13,11 +13,9 @@ class DummyModelType:
pass pass
@strawberry.type @strawberry.type(name="Query")
class DummyQuery: class DummyQuery:
@strawberry.field dummymodel: DummyModelType = strawberry_django.field()
def dummymodel(self, id: int) -> DummyModelType:
return None
dummymodel_list: List[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.test import override_settings
from django.urls import reverse 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): class GraphQLTestCase(TestCase):
@ -34,3 +40,45 @@ class GraphQLTestCase(TestCase):
response = self.client.get(url, **header) response = self.client.get(url, **header)
with disable_warnings('django.request'): with disable_warnings('django.request'):
self.assertHttpStatus(response, 302) # Redirect to login page 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.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_tables2.export import TableExport from django_tables2.export import TableExport
from mptt.models import MPTTModel
from core.models import ObjectType from core.models import ObjectType
from core.signals import clear_events from core.signals import clear_events
@ -178,6 +179,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
table.columns.hide('pk') table.columns.hide('pk')
return render(request, 'htmx/table.html', { return render(request, 'htmx/table.html', {
'table': table, 'table': table,
'model': model,
'actions': actions,
}) })
context = { context = {
@ -612,6 +615,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
if form.cleaned_data.get('remove_tags', None): if form.cleaned_data.get('remove_tags', None):
obj.tags.remove(*form.cleaned_data['remove_tags']) 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 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 // 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. // a "Create & Add", so we need to add a listener to both.
const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]'); const submitters = form.querySelectorAll<HTMLButtonElement>('button[type=submit]');
for (const submitter of submitters) { for (const submitter of submitters) {
// Add the event listener to each submitter. // Add the event listener to each submitter.
submitter.addEventListener('click', (event: Event) => handleFormSubmit(event, form)); 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 { initFormElements } from './elements';
import { initSpeedSelector } from './speedSelector'; import { initSpeedSelector } from './speedSelector';
import { initScopeSelector } from './scopeSelector';
export function initForms(): void { export function initForms(): void {
for (const func of [initFormElements, initSpeedSelector, initScopeSelector]) { for (const func of [initFormElements, initSpeedSelector]) {
func(); 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 // Remove the bottom margin of <p> elements inside a table cell
td > .rendered-markdown { td > .rendered-markdown {
max-height: 200px;
overflow-y: scroll;
p:last-of-type { p:last-of-type {
margin-bottom: 0; margin-bottom: 0;
} }

View File

@ -25,3 +25,14 @@ hr.dropdown-divider {
.dropdown-item { .dropdown-item {
font-weight: $font-weight-base; 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 { pre code {
padding: unset; 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> <tr>
<th scope="row" class="ps-3">{% trans "Custom validators" %}</th> <th scope="row" class="ps-3">{% trans "Custom validators" %}</th>
{% if config.CUSTOM_VALIDATORS %} {% if config.CUSTOM_VALIDATORS %}
<td><pre>{{ config.CUSTOM_VALIDATORS|json }}</pre></td> <td><pre>{{ config.CUSTOM_VALIDATORS }}</pre></td>
{% else %} {% else %}
<td>{{ ''|placeholder }}</td> <td>{{ ''|placeholder }}</td>
{% endif %} {% endif %}

View File

@ -10,15 +10,17 @@
gs-id="{{ widget.id }}" gs-id="{{ widget.id }}"
> >
<div class="card grid-stack-item-content"> <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="#" <a href="#"
hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}" hx-get="{% url 'extras:dashboardwidget_config' id=widget.id %}"
hx-target="#htmx-modal-content" hx-target="#htmx-modal-content"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#htmx-modal" data-bs-target="#htmx-modal"
class="text-bg-{{ bg_color }}"
aria-label="{{ widget.title }} {% trans "widget configuration" %}" 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> </a>
<div class="card-title flex-fill text-center"> <div class="card-title flex-fill text-center">
{% if widget.title %} {% if widget.title %}
@ -30,11 +32,13 @@
hx-target="#htmx-modal-content" hx-target="#htmx-modal-content"
data-bs-toggle="modal" data-bs-toggle="modal"
data-bs-target="#htmx-modal" data-bs-target="#htmx-modal"
class="text-bg-{{ bg_color }}"
aria-label="{% trans "Close widget" %} {{ widget.title }}" 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> </a>
</div> </div>
{% endwith %}
<div class="card-body p-2 pt-1 overflow-auto"> <div class="card-body p-2 pt-1 overflow-auto">
{% render_widget widget %} {% render_widget widget %}
</div> </div>

View File

@ -41,7 +41,11 @@
<div class="card"> <div class="card">
<div class="table-responsive" id="object_list"> <div class="table-responsive" id="object_list">
<h2 class="card-header">{% trans "Log" %}</h2> <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>
</div> </div>
{% endif %} {% endif %}

View File

@ -108,14 +108,6 @@
</div> </div>
{# /Object list tab #} {# /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 %} {% endblock content %}
{% block modals %} {% block modals %}

View File

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

View File

@ -1,5 +1,6 @@
{# Render an HTMX-enabled table with paginator #} {# Render an HTMX-enabled table with paginator #}
{% load helpers %} {% load helpers %}
{% load buttons %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
<div class="htmx-container table-responsive"> <div class="htmx-container table-responsive">
@ -14,5 +15,19 @@
{% endwith %} {% endwith %}
</div> </div>
{% if request.htmx %}
{# Include the updated object count for display elsewhere on the page #} {# 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> <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> <h2 class="card-header">{% trans "Related Objects" %}</h2>
<ul class="list-group list-group-flush" role="presentation"> <ul class="list-group list-group-flush" role="presentation">
{% for qs, filter_param in related_models %} {% 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"> <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 }} {{ qs.model|meta:"verbose_name_plural"|bettertitle }}
{% with count=qs.count %} {% with count=qs.count %}
@ -16,6 +17,7 @@
{% endif %} {% endif %}
{% endwith %} {% endwith %}
</a> </a>
{% endif %}
{% endwith %} {% endwith %}
{% endfor %} {% endfor %}
</ul> </ul>

View File

@ -81,7 +81,7 @@
<div class="col"> <div class="col">
<a href="{{ backend.url }}" class="btn w-100"> <a href="{{ backend.url }}" class="btn w-100">
{% if backend.icon_name %}<i class="mdi mdi-{{ backend.icon_name }}"></i> {% 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 }} {{ backend.display_name }}
</a> </a>
</div> </div>

View File

@ -3,38 +3,25 @@ from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
from tenancy import models
from .types import * from .types import *
@strawberry.type @strawberry.type(name="Query")
class TenancyQuery: class TenancyQuery:
@strawberry.field tenant: TenantType = strawberry_django.field()
def tenant(self, id: int) -> TenantType:
return models.Tenant.objects.get(pk=id)
tenant_list: List[TenantType] = strawberry_django.field() tenant_list: List[TenantType] = strawberry_django.field()
@strawberry.field tenant_group: TenantGroupType = strawberry_django.field()
def tenant_group(self, id: int) -> TenantGroupType:
return models.TenantGroup.objects.get(pk=id)
tenant_group_list: List[TenantGroupType] = strawberry_django.field() tenant_group_list: List[TenantGroupType] = strawberry_django.field()
@strawberry.field contact: ContactType = strawberry_django.field()
def contact(self, id: int) -> ContactType:
return models.Contact.objects.get(pk=id)
contact_list: List[ContactType] = strawberry_django.field() contact_list: List[ContactType] = strawberry_django.field()
@strawberry.field contact_role: ContactRoleType = strawberry_django.field()
def contact_role(self, id: int) -> ContactRoleType:
return models.ContactRole.objects.get(pk=id)
contact_role_list: List[ContactRoleType] = strawberry_django.field() contact_role_list: List[ContactRoleType] = strawberry_django.field()
@strawberry.field contact_group: ContactGroupType = strawberry_django.field()
def contact_group(self, id: int) -> ContactGroupType:
return models.ContactGroup.objects.get(pk=id)
contact_group_list: List[ContactGroupType] = strawberry_django.field() contact_group_list: List[ContactGroupType] = strawberry_django.field()
@strawberry.field contact_assignment: ContactAssignmentType = strawberry_django.field()
def contact_assignment(self, id: int) -> ContactAssignmentType:
return models.ContactAssignment.objects.get(pk=id)
contact_assignment_list: List[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( contact_phone = tables.Column(
accessor=Accessor('contact__phone'), 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'), accessor=Accessor('contact__email'),
verbose_name=_('Contact Email') verbose_name=_('Contact Email'),
) )
contact_address = tables.Column( contact_address = tables.Column(
accessor=Accessor('contact__address'), 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') brief_fields = ('id', 'url', 'display', 'key', 'write_enabled', 'description')
def to_internal_value(self, data): 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() data['key'] = Token.generate_key()
return super().to_internal_value(data) return super().to_internal_value(data)

View File

@ -3,18 +3,13 @@ from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
from users import models
from .types import * from .types import *
@strawberry.type @strawberry.type(name="Query")
class UsersQuery: class UsersQuery:
@strawberry.field group: GroupType = strawberry_django.field()
def group(self, id: int) -> GroupType:
return models.Group.objects.get(pk=id)
group_list: List[GroupType] = strawberry_django.field() group_list: List[GroupType] = strawberry_django.field()
@strawberry.field user: UserType = strawberry_django.field()
def user(self, id: int) -> UserType:
return models.User.objects.get(pk=id)
user_list: List[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 from django.core.serializers.json import DjangoJSONEncoder
__all__ = ( __all__ = (
'ConfigJSONEncoder',
'CustomFieldJSONEncoder', 'CustomFieldJSONEncoder',
) )
@ -15,3 +16,16 @@ class CustomFieldJSONEncoder(DjangoJSONEncoder):
if isinstance(o, decimal.Decimal): if isinstance(o, decimal.Decimal):
return float(o) return float(o)
return super().default(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.conf import settings
from django.apps import apps
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER
__all__ = ( __all__ = (
'get_permission_for_model', 'get_permission_for_model',
'permission_is_exempt', 'permission_is_exempt',
@ -90,6 +93,11 @@ def qs_filter_from_constraints(constraints, tokens=None):
if tokens is None: if tokens is None:
tokens = {} 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): def _replace_tokens(value, tokens):
if type(value) is list: if type(value) is list:
return list(map(lambda v: tokens.get(v, v), value)) 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.contrib.humanize.templatetags.humanize import naturalday, naturaltime
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.timezone import localtime
from markdown import markdown from markdown import markdown
from markdown.extensions.tables import TableExtension from markdown.extensions.tables import TableExtension
@ -218,7 +219,8 @@ def isodate(value):
text = value.isoformat() text = value.isoformat()
return mark_safe(f'<span title="{naturalday(value)}">{text}</span>') return mark_safe(f'<span title="{naturalday(value)}">{text}</span>')
elif type(value) is datetime.datetime: 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>') return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
else: else:
return '' return ''
@ -229,7 +231,8 @@ def isotime(value, spec='seconds'):
if type(value) is datetime.time: if type(value) is datetime.time:
return value.isoformat(timespec=spec) return value.isoformat(timespec=spec)
if type(value) is datetime.datetime: 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 '' return ''

View File

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

View File

@ -107,6 +107,16 @@ def disable_warnings(logger_name):
logger.setLevel(current_level) 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 # Custom field testing
# #

View File

@ -3,38 +3,25 @@ from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
from virtualization import models
from .types import * from .types import *
@strawberry.type @strawberry.type(name="Query")
class VirtualizationQuery: class VirtualizationQuery:
@strawberry.field cluster: ClusterType = strawberry_django.field()
def cluster(self, id: int) -> ClusterType:
return models.Cluster.objects.get(pk=id)
cluster_list: List[ClusterType] = strawberry_django.field() cluster_list: List[ClusterType] = strawberry_django.field()
@strawberry.field cluster_group: ClusterGroupType = strawberry_django.field()
def cluster_group(self, id: int) -> ClusterGroupType:
return models.ClusterGroup.objects.get(pk=id)
cluster_group_list: List[ClusterGroupType] = strawberry_django.field() cluster_group_list: List[ClusterGroupType] = strawberry_django.field()
@strawberry.field cluster_type: ClusterTypeType = strawberry_django.field()
def cluster_type(self, id: int) -> ClusterTypeType:
return models.ClusterType.objects.get(pk=id)
cluster_type_list: List[ClusterTypeType] = strawberry_django.field() cluster_type_list: List[ClusterTypeType] = strawberry_django.field()
@strawberry.field virtual_machine: VirtualMachineType = strawberry_django.field()
def virtual_machine(self, id: int) -> VirtualMachineType:
return models.VirtualMachine.objects.get(pk=id)
virtual_machine_list: List[VirtualMachineType] = strawberry_django.field() virtual_machine_list: List[VirtualMachineType] = strawberry_django.field()
@strawberry.field vm_interface: VMInterfaceType = strawberry_django.field()
def vm_interface(self, id: int) -> VMInterfaceType:
return models.VMInterface.objects.get(pk=id)
vm_interface_list: List[VMInterfaceType] = strawberry_django.field() vm_interface_list: List[VMInterfaceType] = strawberry_django.field()
@strawberry.field virtual_disk: VirtualDiskType = strawberry_django.field()
def virtual_disk(self, id: int) -> VirtualDiskType:
return models.VirtualDisk.objects.get(pk=id)
virtual_disk_list: List[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
import strawberry_django import strawberry_django
from vpn import models
from .types import * from .types import *
@strawberry.type @strawberry.type(name="Query")
class VPNQuery: class VPNQuery:
@strawberry.field ike_policy: IKEPolicyType = strawberry_django.field()
def ike_policy(self, id: int) -> IKEPolicyType:
return models.IKEPolicy.objects.get(pk=id)
ike_policy_list: List[IKEPolicyType] = strawberry_django.field() ike_policy_list: List[IKEPolicyType] = strawberry_django.field()
@strawberry.field ike_proposal: IKEProposalType = strawberry_django.field()
def ike_proposal(self, id: int) -> IKEProposalType:
return models.IKEProposal.objects.get(pk=id)
ike_proposal_list: List[IKEProposalType] = strawberry_django.field() ike_proposal_list: List[IKEProposalType] = strawberry_django.field()
@strawberry.field ipsec_policy: IPSecPolicyType = strawberry_django.field()
def ipsec_policy(self, id: int) -> IPSecPolicyType:
return models.IPSecPolicy.objects.get(pk=id)
ipsec_policy_list: List[IPSecPolicyType] = strawberry_django.field() ipsec_policy_list: List[IPSecPolicyType] = strawberry_django.field()
@strawberry.field ipsec_profile: IPSecProfileType = strawberry_django.field()
def ipsec_profile(self, id: int) -> IPSecProfileType:
return models.IPSecProfile.objects.get(pk=id)
ipsec_profile_list: List[IPSecProfileType] = strawberry_django.field() ipsec_profile_list: List[IPSecProfileType] = strawberry_django.field()
@strawberry.field ipsec_proposal: IPSecProposalType = strawberry_django.field()
def ipsec_proposal(self, id: int) -> IPSecProposalType:
return models.IPSecProposal.objects.get(pk=id)
ipsec_proposal_list: List[IPSecProposalType] = strawberry_django.field() ipsec_proposal_list: List[IPSecProposalType] = strawberry_django.field()
@strawberry.field l2vpn: L2VPNType = strawberry_django.field()
def l2vpn(self, id: int) -> L2VPNType:
return models.L2VPN.objects.get(pk=id)
l2vpn_list: List[L2VPNType] = strawberry_django.field() l2vpn_list: List[L2VPNType] = strawberry_django.field()
@strawberry.field l2vpn_termination: L2VPNTerminationType = strawberry_django.field()
def l2vpn_termination(self, id: int) -> L2VPNTerminationType:
return models.L2VPNTermination.objects.get(pk=id)
l2vpn_termination_list: List[L2VPNTerminationType] = strawberry_django.field() l2vpn_termination_list: List[L2VPNTerminationType] = strawberry_django.field()
@strawberry.field tunnel: TunnelType = strawberry_django.field()
def tunnel(self, id: int) -> TunnelType:
return models.Tunnel.objects.get(pk=id)
tunnel_list: List[TunnelType] = strawberry_django.field() tunnel_list: List[TunnelType] = strawberry_django.field()
@strawberry.field tunnel_group: TunnelGroupType = strawberry_django.field()
def tunnel_group(self, id: int) -> TunnelGroupType:
return models.TunnelGroup.objects.get(pk=id)
tunnel_group_list: List[TunnelGroupType] = strawberry_django.field() tunnel_group_list: List[TunnelGroupType] = strawberry_django.field()
@strawberry.field tunnel_termination: TunnelTerminationType = strawberry_django.field()
def tunnel_termination(self, id: int) -> TunnelTerminationType:
return models.TunnelTermination.objects.get(pk=id)
tunnel_termination_list: List[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
import strawberry_django import strawberry_django
from wireless import models
from .types import * from .types import *
@strawberry.type @strawberry.type(name="Query")
class WirelessQuery: class WirelessQuery:
@strawberry.field wireless_lan: WirelessLANType = strawberry_django.field()
def wireless_lan(self, id: int) -> WirelessLANType:
return models.WirelessLAN.objects.get(pk=id)
wireless_lan_list: List[WirelessLANType] = strawberry_django.field() wireless_lan_list: List[WirelessLANType] = strawberry_django.field()
@strawberry.field wireless_lan_group: WirelessLANGroupType = strawberry_django.field()
def wireless_lan_group(self, id: int) -> WirelessLANGroupType:
return models.WirelessLANGroup.objects.get(pk=id)
wireless_lan_group_list: List[WirelessLANGroupType] = strawberry_django.field() wireless_lan_group_list: List[WirelessLANGroupType] = strawberry_django.field()
@strawberry.field wireless_link: WirelessLinkType = strawberry_django.field()
def wireless_link(self, id: int) -> WirelessLinkType:
return models.WirelessLink.objects.get(pk=id)
wireless_link_list: List[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 feedparser==6.0.11
gunicorn==23.0.0 gunicorn==23.0.0
Jinja2==3.1.4 Jinja2==3.1.4
Markdown==3.6 Markdown==3.7
mkdocs-material==9.5.31 mkdocs-material==9.5.33
mkdocstrings[python-legacy]==0.25.2 mkdocstrings[python-legacy]==0.25.2
netaddr==1.3.0 netaddr==1.3.0
nh3==0.2.18 nh3==0.2.18