Compare commits

..

11 Commits

Author SHA1 Message Date
Arthur
76caae12fa 19724 change from old to V1 2025-10-22 08:57:36 -07:00
Arthur
26c91f01c6 19724 update docs 2025-10-21 10:27:10 -07:00
Arthur
af55da008b 19724 add the v2 to graphql testing 2025-10-21 10:16:16 -07:00
Arthur
810d1c2418 19724 add the v2 to graphql testing 2025-10-21 10:01:00 -07:00
Arthur
91b2d61ea4 19724 Use v2 API for new pagination queries 2025-10-20 16:52:46 -07:00
Arthur
b7b7b00885 Merge branch 'feature' into 19724-graphql 2025-10-20 16:29:32 -07:00
Arthur
595b343cd0 19724 add doc note 2025-10-14 14:38:16 -07:00
Arthur
730aee9b26 19724 fix doc query 2025-10-14 14:15:16 -07:00
Arthur
8aa1e2802b 19724 fix tests 2025-10-14 14:06:15 -07:00
Arthur
c2d19119cb 19724 update documentation 2025-10-14 13:54:58 -07:00
Arthur
0c4d0fa2e8 19724 pagingate graphql queries 2025-10-14 13:46:30 -07:00
68 changed files with 882 additions and 1402 deletions

View File

@@ -90,10 +90,3 @@ http://netbox:8000/api/extras/config-templates/123/render/ \
"bar": 123
}'
```
!!! note "Permissions"
Rendering configuration templates via the REST API requires appropriate permissions for the relevant object type:
* To render a device's configuration via `/api/dcim/devices/{id}/render-config/`, assign a permission for "DCIM > Device" with the `render_config` action.
* To render a virtual machine's configuration via `/api/virtualization/virtual-machines/{id}/render-config/`, assign a permission for "Virtualization > Virtual Machine" with the `render_config` action.
* To render a config template directly via `/api/extras/config-templates/{id}/render/`, assign a permission for "Extras > Config Template" with the `render` action.

View File

@@ -11,7 +11,7 @@ curl -H "Authorization: Token $TOKEN" \
-H "Content-Type: application/json" \
-H "Accept: application/json" \
http://netbox/graphql/ \
--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {cid provider {name}}}"}'
--data '{"query": "query {circuit_list(filters:{status: STATUS_ACTIVE}) {results {cid provider {name}}}}"}'
```
The response will include the requested data formatted as JSON:
@@ -36,6 +36,30 @@ The response will include the requested data formatted as JSON:
}
}
```
If using the GraphQL API v2 the format will be:
```json
{
"data": {
"circuit_list": {
"results": [
{
"cid": "1002840283",
"provider": {
"name": "CenturyLink"
}
},
{
"cid": "1002840457",
"provider": {
"name": "CenturyLink"
}
}
]
}
}
}
```
!!! note
It's recommended to pass the return data through a JSON parser such as `jq` for better readability.
@@ -47,12 +71,15 @@ NetBox provides both a singular and plural query field for each object type:
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
!!! note "Changed in NetBox v4.5"
If using the GraphQL API v2, List queries now return paginated results. The actual objects are contained within the `results` field of the response, along with `total_count` and `page_info` fields for pagination metadata. Prior to v4.5, list queries returned objects directly as an array.
For more detail on constructing GraphQL queries, see the [GraphQL queries documentation](https://graphql.org/learn/queries/). For filtering and lookup syntax, please refer to the [Strawberry Django documentation](https://strawberry.rocks/docs/django/guide/filters).
## Filtering
!!! note "Changed in NetBox v4.3"
The filtering syntax fo the GraphQL API has changed substantially in NetBox v4.3.
The filtering syntax for the GraphQL API has changed substantially in NetBox v4.3.
Filters can be specified as key-value pairs within parentheses immediately following the query name. For example, the following will return only active sites:
@@ -67,6 +94,21 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
site_list(
filters: {
status: STATUS_ACTIVE
}
) {
results {
name
}
}
}
```
Filters can be combined with logical operators, such as `OR` and `NOT`. For example, the following will return every site that is planned _or_ assigned to a tenant named Foo:
@@ -88,6 +130,28 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
site_list(
filters: {
status: STATUS_PLANNED,
OR: {
tenant: {
name: {
exact: "Foo"
}
}
}
}
) {
results {
name
}
}
}
```
Filtering can also be applied to related objects. For example, the following query will return only enabled interfaces for each device:
@@ -102,6 +166,21 @@ query {
}
}
```
If using the GraphQL API v2 the format will be:
```
query {
device_list {
results {
id
name
interfaces(filters: {enabled: {exact: true}}) {
name
}
}
}
}
```
## Multiple Return Types
@@ -128,6 +207,31 @@ Certain queries can return multiple types of objects, for example cable terminat
}
}
```
If using the GraphQL API v2 the format will be:
```
{
cable_list {
results {
id
a_terminations {
... on CircuitTerminationType {
id
class_type
}
... on ConsolePortType {
id
class_type
}
... on ConsoleServerPortType {
id
class_type
}
}
}
}
}
```
The field "class_type" is an easy way to distinguish what type of object it is when viewing the returned data, or when filtering. It contains the class name, for example "CircuitTermination" or "ConsoleServerPort".
@@ -142,6 +246,47 @@ query {
}
}
```
### Pagination in GraphQL API V2
All list queries return paginated results using the `OffsetPaginated` type, which includes:
- `results`: The list of objects matching the query
- `total_count`: The total number of objects matching the filters (without pagination)
- `page_info`: Pagination metadata including `offset` and `limit`
By default, queries return up to 100 results. You can control pagination by specifying the `pagination` parameter with `offset` and `limit` values:
```
query {
device_list(pagination: { offset: 0, limit: 20 }) {
total_count
page_info {
offset
limit
}
results {
id
name
}
}
}
```
If you don't need pagination metadata, you can simply query the `results`:
```
query {
device_list {
results {
id
name
}
}
}
```
!!! note
When not specifying the `pagination` parameter, avoid querying `page_info.limit` as it may return an undefined value. Either provide explicit pagination parameters or only query the `results` and `total_count` fields.
## Authentication

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class CircuitsQuery:
class CircuitsQueryV1:
circuit: CircuitType = strawberry_django.field()
circuit_list: List[CircuitType] = strawberry_django.field()
@@ -40,3 +41,41 @@ class CircuitsQuery:
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: List[VirtualCircuitTypeType] = strawberry_django.field()
@strawberry.type(name="Query")
class CircuitsQuery:
circuit: CircuitType = strawberry_django.field()
circuit_list: OffsetPaginated[CircuitType] = strawberry_django.offset_paginated()
circuit_termination: CircuitTerminationType = strawberry_django.field()
circuit_termination_list: OffsetPaginated[CircuitTerminationType] = strawberry_django.offset_paginated()
circuit_type: CircuitTypeType = strawberry_django.field()
circuit_type_list: OffsetPaginated[CircuitTypeType] = strawberry_django.offset_paginated()
circuit_group: CircuitGroupType = strawberry_django.field()
circuit_group_list: OffsetPaginated[CircuitGroupType] = strawberry_django.offset_paginated()
circuit_group_assignment: CircuitGroupAssignmentType = strawberry_django.field()
circuit_group_assignment_list: OffsetPaginated[CircuitGroupAssignmentType] = strawberry_django.offset_paginated()
provider: ProviderType = strawberry_django.field()
provider_list: OffsetPaginated[ProviderType] = strawberry_django.offset_paginated()
provider_account: ProviderAccountType = strawberry_django.field()
provider_account_list: OffsetPaginated[ProviderAccountType] = strawberry_django.offset_paginated()
provider_network: ProviderNetworkType = strawberry_django.field()
provider_network_list: OffsetPaginated[ProviderNetworkType] = strawberry_django.offset_paginated()
virtual_circuit: VirtualCircuitType = strawberry_django.field()
virtual_circuit_list: OffsetPaginated[VirtualCircuitType] = strawberry_django.offset_paginated()
virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
virtual_circuit_termination_list: OffsetPaginated[VirtualCircuitTerminationType] = (
strawberry_django.offset_paginated()
)
virtual_circuit_type: VirtualCircuitTypeType = strawberry_django.field()
virtual_circuit_type_list: OffsetPaginated[VirtualCircuitTypeType] = strawberry_django.offset_paginated()

View File

@@ -1,97 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'circuits_circuitgroup_name_ec8ac1e5_like',
'circuits_circuitgroup_slug_61ca866b_like',
'circuits_circuittype_name_8256ea9a_like',
'circuits_circuittype_slug_9b4b3cf9_like',
'circuits_provider_name_8f2514f5_like',
'circuits_provider_slug_c3c0aa10_like',
'circuits_virtualcircuittype_name_5184db16_like',
'circuits_virtualcircuittype_slug_75d5c661_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('circuits', '0052_extend_circuit_abs_distance_upper_limit'),
('dcim', '0217_ci_collations'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='circuit',
name='cid',
field=models.CharField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='circuitgroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='circuitgroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='circuittype',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='circuittype',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='provider',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='provider',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='provideraccount',
name='account',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='provideraccount',
name='name',
field=models.CharField(blank=True, db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='providernetwork',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='virtualcircuit',
name='cid',
field=models.CharField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='virtualcircuittype',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='virtualcircuittype',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
]

View File

@@ -41,10 +41,9 @@ class Circuit(ContactsMixin, ImageAttachmentsMixin, DistanceMixin, PrimaryModel)
ProviderAccount. Circuit port speed and commit rate are measured in Kbps.
"""
cid = models.CharField(
verbose_name=_('circuit ID'),
max_length=100,
db_collation='case_insensitive',
help_text=_('Unique circuit ID'),
verbose_name=_('circuit ID'),
help_text=_('Unique circuit ID')
)
provider = models.ForeignKey(
to='circuits.Provider',

View File

@@ -21,14 +21,13 @@ class Provider(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
help_text=_('Full name of the provider'),
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
asns = models.ManyToManyField(
to='ipam.ASN',
@@ -57,15 +56,13 @@ class ProviderAccount(ContactsMixin, PrimaryModel):
related_name='accounts'
)
account = models.CharField(
verbose_name=_('account ID'),
max_length=100,
db_collation='ci_natural_sort',
verbose_name=_('account ID')
)
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
blank=True,
blank=True
)
clone_fields = ('provider', )
@@ -100,7 +97,7 @@ class ProviderNetwork(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
provider = models.ForeignKey(
to='circuits.Provider',

View File

@@ -34,10 +34,9 @@ class VirtualCircuit(PrimaryModel):
A virtual connection between two or more endpoints, delivered across one or more physical circuits.
"""
cid = models.CharField(
verbose_name=_('circuit ID'),
max_length=100,
db_collation='case_insensitive',
help_text=_('Unique circuit ID'),
verbose_name=_('circuit ID'),
help_text=_('Unique circuit ID')
)
provider_network = models.ForeignKey(
to='circuits.ProviderNetwork',

View File

@@ -2,14 +2,24 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class CoreQuery:
class CoreQueryV1:
data_file: DataFileType = strawberry_django.field()
data_file_list: List[DataFileType] = strawberry_django.field()
data_source: DataSourceType = strawberry_django.field()
data_source_list: List[DataSourceType] = strawberry_django.field()
@strawberry.type(name="Query")
class CoreQuery:
data_file: DataFileType = strawberry_django.field()
data_file_list: OffsetPaginated[DataFileType] = strawberry_django.offset_paginated()
data_source: DataSourceType = strawberry_django.field()
data_source_list: OffsetPaginated[DataSourceType] = strawberry_django.offset_paginated()

View File

@@ -1,30 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'core_datasource_name_17788499_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('core', '0019_configrevision_active'),
('dcim', '0217_ci_collations'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='datasource',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
]

View File

@@ -38,8 +38,7 @@ class DataSource(JobsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
type = models.CharField(
verbose_name=_('type'),

View File

@@ -155,7 +155,7 @@ class PowerOutletTemplateSerializer(ComponentTemplateSerializer):
model = PowerOutletTemplate
fields = [
'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type',
'color', 'power_port', 'feed_leg', 'description', 'created', 'last_updated',
'power_port', 'feed_leg', 'description', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')

View File

@@ -842,7 +842,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceType
class Meta:
model = PowerOutletTemplate
fields = ('id', 'name', 'label', 'type', 'color', 'feed_leg', 'description')
fields = ('id', 'name', 'label', 'type', 'feed_leg', 'description')
class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):

View File

@@ -1163,10 +1163,6 @@ class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm):
choices=add_blank_choice(PowerOutletTypeChoices),
required=False
)
color = ColorField(
label=_('Color'),
required=False
)
power_port = forms.ModelChoiceField(
label=_('Power port'),
queryset=PowerPortTemplate.objects.all(),

View File

@@ -1092,14 +1092,14 @@ class PowerOutletTemplateForm(ModularComponentTemplateForm):
FieldSet('device_type', name=_('Device Type')),
FieldSet('module_type', name=_('Module Type')),
),
'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description',
'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
),
)
class Meta:
model = PowerOutletTemplate
fields = [
'device_type', 'module_type', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description',
'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class DCIMQuery:
class DCIMQueryV1:
cable: CableType = strawberry_django.field()
cable_list: List[CableType] = strawberry_django.field()
@@ -136,3 +137,137 @@ class DCIMQuery:
virtual_device_context: VirtualDeviceContextType = strawberry_django.field()
virtual_device_context_list: List[VirtualDeviceContextType] = strawberry_django.field()
@strawberry.type(name="Query")
class DCIMQuery:
cable: CableType = strawberry_django.field()
cable_list: OffsetPaginated[CableType] = strawberry_django.offset_paginated()
console_port: ConsolePortType = strawberry_django.field()
console_port_list: OffsetPaginated[ConsolePortType] = strawberry_django.offset_paginated()
console_port_template: ConsolePortTemplateType = strawberry_django.field()
console_port_template_list: OffsetPaginated[ConsolePortTemplateType] = strawberry_django.offset_paginated()
console_server_port: ConsoleServerPortType = strawberry_django.field()
console_server_port_list: OffsetPaginated[ConsoleServerPortType] = strawberry_django.offset_paginated()
console_server_port_template: ConsoleServerPortTemplateType = strawberry_django.field()
console_server_port_template_list: OffsetPaginated[ConsoleServerPortTemplateType] = (
strawberry_django.offset_paginated()
)
device: DeviceType = strawberry_django.field()
device_list: OffsetPaginated[DeviceType] = strawberry_django.offset_paginated()
device_bay: DeviceBayType = strawberry_django.field()
device_bay_list: OffsetPaginated[DeviceBayType] = strawberry_django.offset_paginated()
device_bay_template: DeviceBayTemplateType = strawberry_django.field()
device_bay_template_list: OffsetPaginated[DeviceBayTemplateType] = strawberry_django.offset_paginated()
device_role: DeviceRoleType = strawberry_django.field()
device_role_list: OffsetPaginated[DeviceRoleType] = strawberry_django.offset_paginated()
device_type: DeviceTypeType = strawberry_django.field()
device_type_list: OffsetPaginated[DeviceTypeType] = strawberry_django.offset_paginated()
front_port: FrontPortType = strawberry_django.field()
front_port_list: OffsetPaginated[FrontPortType] = strawberry_django.offset_paginated()
front_port_template: FrontPortTemplateType = strawberry_django.field()
front_port_template_list: OffsetPaginated[FrontPortTemplateType] = strawberry_django.offset_paginated()
mac_address: MACAddressType = strawberry_django.field()
mac_address_list: OffsetPaginated[MACAddressType] = strawberry_django.offset_paginated()
interface: InterfaceType = strawberry_django.field()
interface_list: OffsetPaginated[InterfaceType] = strawberry_django.offset_paginated()
interface_template: InterfaceTemplateType = strawberry_django.field()
interface_template_list: OffsetPaginated[InterfaceTemplateType] = strawberry_django.offset_paginated()
inventory_item: InventoryItemType = strawberry_django.field()
inventory_item_list: OffsetPaginated[InventoryItemType] = strawberry_django.offset_paginated()
inventory_item_role: InventoryItemRoleType = strawberry_django.field()
inventory_item_role_list: OffsetPaginated[InventoryItemRoleType] = strawberry_django.offset_paginated()
inventory_item_template: InventoryItemTemplateType = strawberry_django.field()
inventory_item_template_list: OffsetPaginated[InventoryItemTemplateType] = strawberry_django.offset_paginated()
location: LocationType = strawberry_django.field()
location_list: OffsetPaginated[LocationType] = strawberry_django.offset_paginated()
manufacturer: ManufacturerType = strawberry_django.field()
manufacturer_list: OffsetPaginated[ManufacturerType] = strawberry_django.offset_paginated()
module: ModuleType = strawberry_django.field()
module_list: OffsetPaginated[ModuleType] = strawberry_django.offset_paginated()
module_bay: ModuleBayType = strawberry_django.field()
module_bay_list: OffsetPaginated[ModuleBayType] = strawberry_django.offset_paginated()
module_bay_template: ModuleBayTemplateType = strawberry_django.field()
module_bay_template_list: OffsetPaginated[ModuleBayTemplateType] = strawberry_django.offset_paginated()
module_type_profile: ModuleTypeProfileType = strawberry_django.field()
module_type_profile_list: OffsetPaginated[ModuleTypeProfileType] = strawberry_django.offset_paginated()
module_type: ModuleTypeType = strawberry_django.field()
module_type_list: OffsetPaginated[ModuleTypeType] = strawberry_django.offset_paginated()
platform: PlatformType = strawberry_django.field()
platform_list: OffsetPaginated[PlatformType] = strawberry_django.offset_paginated()
power_feed: PowerFeedType = strawberry_django.field()
power_feed_list: OffsetPaginated[PowerFeedType] = strawberry_django.offset_paginated()
power_outlet: PowerOutletType = strawberry_django.field()
power_outlet_list: OffsetPaginated[PowerOutletType] = strawberry_django.offset_paginated()
power_outlet_template: PowerOutletTemplateType = strawberry_django.field()
power_outlet_template_list: OffsetPaginated[PowerOutletTemplateType] = strawberry_django.offset_paginated()
power_panel: PowerPanelType = strawberry_django.field()
power_panel_list: OffsetPaginated[PowerPanelType] = strawberry_django.offset_paginated()
power_port: PowerPortType = strawberry_django.field()
power_port_list: OffsetPaginated[PowerPortType] = strawberry_django.offset_paginated()
power_port_template: PowerPortTemplateType = strawberry_django.field()
power_port_template_list: OffsetPaginated[PowerPortTemplateType] = strawberry_django.offset_paginated()
rack_type: RackTypeType = strawberry_django.field()
rack_type_list: OffsetPaginated[RackTypeType] = strawberry_django.offset_paginated()
rack: RackType = strawberry_django.field()
rack_list: OffsetPaginated[RackType] = strawberry_django.offset_paginated()
rack_reservation: RackReservationType = strawberry_django.field()
rack_reservation_list: OffsetPaginated[RackReservationType] = strawberry_django.offset_paginated()
rack_role: RackRoleType = strawberry_django.field()
rack_role_list: OffsetPaginated[RackRoleType] = strawberry_django.offset_paginated()
rear_port: RearPortType = strawberry_django.field()
rear_port_list: OffsetPaginated[RearPortType] = strawberry_django.offset_paginated()
rear_port_template: RearPortTemplateType = strawberry_django.field()
rear_port_template_list: OffsetPaginated[RearPortTemplateType] = strawberry_django.offset_paginated()
region: RegionType = strawberry_django.field()
region_list: OffsetPaginated[RegionType] = strawberry_django.offset_paginated()
site: SiteType = strawberry_django.field()
site_list: OffsetPaginated[SiteType] = strawberry_django.offset_paginated()
site_group: SiteGroupType = strawberry_django.field()
site_group_list: OffsetPaginated[SiteGroupType] = strawberry_django.offset_paginated()
virtual_chassis: VirtualChassisType = strawberry_django.field()
virtual_chassis_list: OffsetPaginated[VirtualChassisType] = strawberry_django.offset_paginated()
virtual_device_context: VirtualDeviceContextType = strawberry_django.field()
virtual_device_context_list: OffsetPaginated[VirtualDeviceContextType] = strawberry_django.offset_paginated()

View File

@@ -673,7 +673,6 @@ class PowerOutletType(ModularComponentType, CabledObjectMixin, PathEndpointMixin
)
class PowerOutletTemplateType(ModularComponentTemplateType):
power_port: Annotated["PowerPortTemplateType", strawberry.lazy('dcim.graphql.types')] | None
color: str
@strawberry_django.type(

View File

@@ -1,17 +0,0 @@
import utilities.fields
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0215_rackreservation_status'),
]
operations = [
migrations.AddField(
model_name='poweroutlettemplate',
name='color',
field=utilities.fields.ColorField(blank=True, max_length=6),
),
]

View File

@@ -1,26 +0,0 @@
from django.contrib.postgres.operations import CreateCollation
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('dcim', '0216_poweroutlettemplate_color'),
]
operations = [
# Create a case-insensitive collation
CreateCollation(
'case_insensitive',
provider='icu',
locale='und-u-ks-level2',
deterministic=False,
),
# Create a case-insensitive collation with natural sorting
CreateCollation(
'ci_natural_sort',
provider='icu',
locale='und-u-kn-true-ks-level2',
deterministic=False,
),
]

View File

@@ -1,311 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'dcim_devicerole_slug_7952643b_like',
'dcim_devicetype_slug_448745bd_like',
'dcim_inventoryitemrole_name_4c8cfe6d_like',
'dcim_inventoryitemrole_slug_3556c227_like',
'dcim_location_slug_352c5472_like',
'dcim_manufacturer_name_841fcd92_like',
'dcim_manufacturer_slug_00430749_like',
'dcim_moduletypeprofile_name_1709c36e_like',
'dcim_platform_slug_b0908ae4_like',
'dcim_rackrole_name_9077cfcc_like',
'dcim_rackrole_slug_40bbcd3a_like',
'dcim_racktype_slug_6bbb384a_like',
'dcim_region_slug_ff078a66_like',
'dcim_site_name_8fe66c76_like',
'dcim_site_slug_4412c762_like',
'dcim_sitegroup_slug_a11d2b04_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0217_ci_collations'),
('extras', '0134_ci_collations'),
('ipam', '0083_ci_collations'),
('tenancy', '0021_ci_collations'),
('virtualization', '0048_populate_mac_addresses'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.RemoveConstraint(
model_name='device',
name='dcim_device_unique_name_site_tenant',
),
migrations.RemoveConstraint(
model_name='device',
name='dcim_device_unique_name_site',
),
migrations.AlterField(
model_name='consoleport',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='consoleporttemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='consoleserverport',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='consoleserverporttemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='device',
name='name',
field=models.CharField(blank=True, db_collation='ci_natural_sort', max_length=64, null=True),
),
migrations.AlterField(
model_name='devicebay',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='devicebaytemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='devicerole',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='devicerole',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='devicetype',
name='model',
field=models.CharField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='devicetype',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='frontport',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='frontporttemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='interface',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='interfacetemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='inventoryitem',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='inventoryitemrole',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='inventoryitemrole',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='inventoryitemtemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='location',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='location',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='manufacturer',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='manufacturer',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='modulebay',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='modulebaytemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='moduletype',
name='model',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='moduletypeprofile',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='powerfeed',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='poweroutlet',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='poweroutlettemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='powerpanel',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='powerport',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='powerporttemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='rack',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='rackrole',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='rackrole',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='racktype',
name='model',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='racktype',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='rearport',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='rearporttemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='region',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='region',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='site',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='site',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='sitegroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='sitegroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='virtualdevicecontext',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(
models.F('name'), models.F('site'), models.F('tenant'), name='dcim_device_unique_name_site_tenant'
),
),
migrations.AddConstraint(
model_name='device',
constraint=models.UniqueConstraint(
models.F('name'),
models.F('site'),
condition=models.Q(('tenant__isnull', True)),
name='dcim_device_unique_name_site',
violation_error_message='Device name must be unique per site.',
),
),
]

View File

@@ -43,10 +43,10 @@ class ComponentTemplateModel(ChangeLoggedModel, TrackingModelMixin):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
help_text=_(
"{module} is accepted as a substitution for the module bay position when attached to a module type."
),
db_collation="natural_sort"
)
label = models.CharField(
verbose_name=_('label'),
@@ -339,10 +339,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
blank=True,
null=True
)
color = ColorField(
verbose_name=_('color'),
blank=True
)
power_port = models.ForeignKey(
to='dcim.PowerPortTemplate',
on_delete=models.SET_NULL,
@@ -393,7 +389,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
name=self.resolve_name(kwargs.get('module')),
label=self.resolve_label(kwargs.get('module')),
type=self.type,
color=self.color,
power_port=power_port,
feed_leg=self.feed_leg,
**kwargs
@@ -404,7 +399,6 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
return {
'name': self.name,
'type': self.type,
'color': self.color,
'power_port': self.power_port.name if self.power_port else None,
'feed_leg': self.feed_leg,
'label': self.label,

View File

@@ -52,7 +52,7 @@ class ComponentModel(NetBoxModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
label = models.CharField(
verbose_name=_('label'),

View File

@@ -1,7 +1,8 @@
import decimal
import yaml
from functools import cached_property
import yaml
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
@@ -9,6 +10,7 @@ from django.core.files.storage import default_storage
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models import F, ProtectedError, prefetch_related_objects
from django.db.models.functions import Lower
from django.db.models.signals import post_save
from django.urls import reverse
from django.utils.safestring import mark_safe
@@ -23,8 +25,8 @@ from extras.querysets import ConfigContextModelQuerySet
from netbox.choices import ColorChoices
from netbox.config import ConfigItem
from netbox.models import NestedGroupModel, OrganizationalModel, PrimaryModel
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from netbox.models.mixins import WeightMixin
from netbox.models.features import ContactsMixin, ImageAttachmentsMixin
from utilities.fields import ColorField, CounterCacheField
from utilities.prefetch import get_prefetchable_fields
from utilities.tracking import TrackingModelMixin
@@ -32,6 +34,7 @@ from .device_components import *
from .mixins import RenderConfigMixin
from .modules import Module
__all__ = (
'Device',
'DeviceRole',
@@ -80,13 +83,11 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
model = models.CharField(
verbose_name=_('model'),
max_length=100,
db_collation='case_insensitive',
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
db_collation='case_insensitive',
max_length=100
)
default_platform = models.ForeignKey(
to='dcim.Platform',
@@ -524,7 +525,7 @@ class Device(
max_length=64,
blank=True,
null=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
serial = models.CharField(
max_length=50,
@@ -720,11 +721,11 @@ class Device(
ordering = ('name', 'pk') # Name may be null
constraints = (
models.UniqueConstraint(
'name', 'site', 'tenant',
Lower('name'), 'site', 'tenant',
name='%(app_label)s_%(class)s_unique_name_site_tenant'
),
models.UniqueConstraint(
'name', 'site',
Lower('name'), 'site',
name='%(app_label)s_%(class)s_unique_name_site',
condition=Q(tenant__isnull=True),
violation_error_message=_("Device name must be unique per site.")
@@ -1118,7 +1119,7 @@ class VirtualChassis(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='natural_sort',
db_collation="natural_sort"
)
domain = models.CharField(
verbose_name=_('domain'),
@@ -1181,7 +1182,7 @@ class VirtualDeviceContext(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
status = models.CharField(
verbose_name=_('status'),

View File

@@ -31,8 +31,7 @@ class ModuleTypeProfile(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
schema = models.JSONField(
blank=True,
@@ -73,8 +72,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
model = models.CharField(
verbose_name=_('model'),
max_length=100,
db_collation='ci_natural_sort',
max_length=100
)
part_number = models.CharField(
verbose_name=_('part number'),

View File

@@ -37,7 +37,7 @@ class PowerPanel(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
prerequisite_models = (
@@ -88,7 +88,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
status = models.CharField(
verbose_name=_('status'),

View File

@@ -137,14 +137,12 @@ class RackType(RackBase):
)
model = models.CharField(
verbose_name=_('model'),
max_length=100,
db_collation='ci_natural_sort',
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
clone_fields = (
@@ -264,7 +262,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
facility_id = models.CharField(
max_length=50,

View File

@@ -142,14 +142,13 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
help_text=_("Full name of the site")
help_text=_("Full name of the site"),
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
status = models.CharField(
verbose_name=_('status'),

View File

@@ -211,9 +211,6 @@ class PowerPortTemplateTable(ComponentTemplateTable):
class PowerOutletTemplateTable(ComponentTemplateTable):
color = columns.ColorColumn(
verbose_name=_('Color'),
)
actions = columns.ActionsColumn(
actions=('edit', 'delete'),
extra_buttons=MODULAR_COMPONENT_TEMPLATE_BUTTONS
@@ -221,7 +218,7 @@ class PowerOutletTemplateTable(ComponentTemplateTable):
class Meta(ComponentTemplateTable.Meta):
model = models.PowerOutletTemplate
fields = ('pk', 'name', 'label', 'type', 'color', 'power_port', 'feed_leg', 'description', 'actions')
fields = ('pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'actions')
empty_text = "None"

View File

@@ -13,8 +13,7 @@ from ipam.choices import VLANQinQRoleChoices
from ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant
from users.constants import TOKEN_PREFIX
from users.models import Token, User
from users.models import User
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_logging
from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices
@@ -1307,6 +1306,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
}
user_permissions = (
'dcim.view_site', 'dcim.view_rack', 'dcim.view_location', 'dcim.view_devicerole', 'dcim.view_devicetype',
'extras.view_configtemplate',
)
@classmethod
@@ -1486,58 +1486,12 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
device.config_template = configtemplate
device.save()
self.add_permissions('dcim.render_config_device', 'dcim.view_device')
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
self.add_permissions('dcim.add_device')
url = reverse('dcim-api:device-detail', kwargs={'pk': device.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Config for device {device.name}')
def test_render_config_without_permission(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for device {{ device.name }}'
)
device = Device.objects.first()
device.config_template = configtemplate
device.save()
# No permissions added - user has no render_config permission
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
def test_render_config_token_write_enabled(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for device {{ device.name }}'
)
device = Device.objects.first()
device.config_template = configtemplate
device.save()
self.add_permissions('dcim.render_config_device', 'dcim.view_device')
url = reverse('dcim-api:device-render-config', kwargs={'pk': device.pk})
# Request without token auth should fail with PermissionDenied
response = self.client.post(url, {}, format='json')
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Create token with write_enabled=False
token = Token.objects.create(version=2, user=self.user, write_enabled=False)
token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
# Request with write-disabled token should fail
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Enable write and retry
token.write_enabled = True
token.save()
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ModuleTest(APIViewTestCases.APIViewTestCase):
model = Module

View File

@@ -1919,21 +1919,18 @@ class PowerOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
device_type=device_types[0],
name='Power Outlet 1',
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A,
color=ColorChoices.COLOR_RED,
description='foobar1'
),
PowerOutletTemplate(
device_type=device_types[1],
name='Power Outlet 2',
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_B,
color=ColorChoices.COLOR_GREEN,
description='foobar2'
),
PowerOutletTemplate(
device_type=device_types[2],
name='Power Outlet 3',
feed_leg=PowerOutletFeedLegChoices.FEED_LEG_C,
color=ColorChoices.COLOR_BLUE,
description='foobar3'
),
))
@@ -1946,10 +1943,6 @@ class PowerOutletTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTest
params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_color(self):
params = {'color': [ColorChoices.COLOR_RED, ColorChoices.COLOR_GREEN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class InterfaceTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
queryset = InterfaceTemplate.objects.all()

View File

@@ -4,7 +4,6 @@ from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from netbox.api.authentication import TokenWritePermission
from netbox.api.renderers import TextRenderer
from .serializers import ConfigTemplateSerializer
@@ -65,24 +64,12 @@ class RenderConfigMixin(ConfigTemplateRenderMixin):
"""
Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
"""
def get_permissions(self):
# For render_config action, check only token write ability (not model permissions)
if self.action == 'render_config':
return [TokenWritePermission()]
return super().get_permissions()
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
def render_config(self, request, pk):
"""
Resolve and render the preferred ConfigTemplate for this Device.
"""
# Override restrict() on the default queryset to enforce the render_config & view actions
self.queryset = self.queryset.model.objects.restrict(request.user, 'render_config').restrict(
request.user, 'view'
)
instance = self.get_object()
object_type = instance._meta.model_name
configtemplate = instance.get_config_template()
if not configtemplate:

View File

@@ -16,7 +16,7 @@ from rq import Worker
from extras import filtersets
from extras.jobs import ScriptJob
from extras.models import *
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired, TokenWritePermission
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.features import SyncedDataMixin
from netbox.api.metadata import ContentTypeMetadata
from netbox.api.renderers import TextRenderer
@@ -238,22 +238,13 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
serializer_class = serializers.ConfigTemplateSerializer
filterset_class = filtersets.ConfigTemplateFilterSet
def get_permissions(self):
# For render action, check only token write ability (not model permissions)
if self.action == 'render':
return [TokenWritePermission()]
return super().get_permissions()
@action(detail=True, methods=['post'], renderer_classes=[JSONRenderer, TextRenderer])
def render(self, request, pk):
"""
Render a ConfigTemplate using the context data provided (if any). If the client requests "text/plain" data,
return the raw rendered content, rather than serialized JSON.
"""
# Override restrict() on the default queryset to enforce the render & view actions
self.queryset = self.queryset.model.objects.restrict(request.user, 'render').restrict(request.user, 'view')
configtemplate = self.get_object()
context = request.data
return self.render_configtemplate(request, configtemplate, context)

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class ExtrasQuery:
class ExtrasQueryV1:
config_context: ConfigContextType = strawberry_django.field()
config_context_list: List[ConfigContextType] = strawberry_django.field()
@@ -58,3 +59,57 @@ class ExtrasQuery:
event_rule: EventRuleType = strawberry_django.field()
event_rule_list: List[EventRuleType] = strawberry_django.field()
@strawberry.type(name="Query")
class ExtrasQuery:
config_context: ConfigContextType = strawberry_django.field()
config_context_list: OffsetPaginated[ConfigContextType] = strawberry_django.offset_paginated()
config_context_profile: ConfigContextProfileType = strawberry_django.field()
config_context_profile_list: OffsetPaginated[ConfigContextProfileType] = strawberry_django.offset_paginated()
config_template: ConfigTemplateType = strawberry_django.field()
config_template_list: OffsetPaginated[ConfigTemplateType] = strawberry_django.offset_paginated()
custom_field: CustomFieldType = strawberry_django.field()
custom_field_list: OffsetPaginated[CustomFieldType] = strawberry_django.offset_paginated()
custom_field_choice_set: CustomFieldChoiceSetType = strawberry_django.field()
custom_field_choice_set_list: OffsetPaginated[CustomFieldChoiceSetType] = strawberry_django.offset_paginated()
custom_link: CustomLinkType = strawberry_django.field()
custom_link_list: OffsetPaginated[CustomLinkType] = strawberry_django.offset_paginated()
export_template: ExportTemplateType = strawberry_django.field()
export_template_list: OffsetPaginated[ExportTemplateType] = strawberry_django.offset_paginated()
image_attachment: ImageAttachmentType = strawberry_django.field()
image_attachment_list: OffsetPaginated[ImageAttachmentType] = strawberry_django.offset_paginated()
saved_filter: SavedFilterType = strawberry_django.field()
saved_filter_list: OffsetPaginated[SavedFilterType] = strawberry_django.offset_paginated()
table_config: TableConfigType = strawberry_django.field()
table_config_list: OffsetPaginated[TableConfigType] = strawberry_django.offset_paginated()
journal_entry: JournalEntryType = strawberry_django.field()
journal_entry_list: OffsetPaginated[JournalEntryType] = strawberry_django.offset_paginated()
notification: NotificationType = strawberry_django.field()
notification_list: OffsetPaginated[NotificationType] = strawberry_django.offset_paginated()
notification_group: NotificationGroupType = strawberry_django.field()
notification_group_list: OffsetPaginated[NotificationGroupType] = strawberry_django.offset_paginated()
subscription: SubscriptionType = strawberry_django.field()
subscription_list: OffsetPaginated[SubscriptionType] = strawberry_django.offset_paginated()
tag: TagType = strawberry_django.field()
tag_list: OffsetPaginated[TagType] = strawberry_django.offset_paginated()
webhook: WebhookType = strawberry_django.field()
webhook_list: OffsetPaginated[WebhookType] = strawberry_django.offset_paginated()
event_rule: EventRuleType = strawberry_django.field()
event_rule_list: OffsetPaginated[EventRuleType] = strawberry_django.offset_paginated()

View File

@@ -1,114 +0,0 @@
import django.core.validators
import re
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'extras_configcontext_name_4bbfe25d_like',
'extras_configcontextprofile_name_070de83b_like',
'extras_customfield_name_2fe72707_like',
'extras_customfieldchoiceset_name_963e63ea_like',
'extras_customlink_name_daed2d18_like',
'extras_eventrule_name_899453c6_like',
'extras_notificationgroup_name_70b0a3f9_like',
'extras_savedfilter_name_8a4bbd09_like',
'extras_savedfilter_slug_4f93a959_like',
'extras_tag_name_9550b3d9_like',
'extras_tag_slug_aaa5b7e9_like',
'extras_webhook_name_82cf60b5_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('extras', '0133_make_cf_minmax_decimal'),
('dcim', '0217_ci_collations'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='configcontext',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='configcontextprofile',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='customfield',
name='name',
field=models.CharField(
db_collation='ci_natural_sort',
max_length=50,
unique=True,
validators=[
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
message='Only alphanumeric characters and underscores are allowed.',
regex='^[a-z0-9_]+$',
),
django.core.validators.RegexValidator(
flags=re.RegexFlag['IGNORECASE'],
inverse_match=True,
message='Double underscores are not permitted in custom field names.',
regex='__',
),
],
),
),
migrations.AlterField(
model_name='customfieldchoiceset',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='customlink',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='eventrule',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=150, unique=True),
),
migrations.AlterField(
model_name='notificationgroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='savedfilter',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='savedfilter',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tag',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tag',
name='slug',
field=models.SlugField(allow_unicode=True, db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='webhook',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=150, unique=True),
),
]

View File

@@ -35,8 +35,7 @@ class ConfigContextProfile(SyncedDataMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
description = models.CharField(
verbose_name=_('description'),
@@ -78,8 +77,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, CustomLinksMixin, ChangeLogge
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
profile = models.ForeignKey(
to='extras.ConfigContextProfile',

View File

@@ -94,7 +94,6 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
verbose_name=_('name'),
max_length=50,
unique=True,
db_collation='ci_natural_sort',
help_text=_('Internal field name'),
validators=(
RegexValidator(
@@ -780,8 +779,7 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel
"""
name = models.CharField(
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
description = models.CharField(
max_length=200,

View File

@@ -59,8 +59,7 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
name = models.CharField(
verbose_name=_('name'),
max_length=150,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
description = models.CharField(
verbose_name=_('description'),
@@ -165,8 +164,7 @@ class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedMo
name = models.CharField(
verbose_name=_('name'),
max_length=150,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
description = models.CharField(
verbose_name=_('description'),
@@ -309,8 +307,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
enabled = models.BooleanField(
verbose_name=_('enabled'),
@@ -471,14 +468,12 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
description = models.CharField(
verbose_name=_('description'),

View File

@@ -125,8 +125,7 @@ class NotificationGroup(ChangeLoggedModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
description = models.CharField(
verbose_name=_('description'),

View File

@@ -2,7 +2,7 @@ from django.conf import settings
from django.db import models
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django.utils.translation import gettext_lazy as _
from taggit.models import TagBase, GenericTaggedItemBase
from netbox.choices import ColorChoices
@@ -25,21 +25,6 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase):
id = models.BigAutoField(
primary_key=True
)
# Override TagBase.name to set db_collation
name = models.CharField(
verbose_name=pgettext_lazy("A tag name", "name"),
unique=True,
max_length=100,
db_collation='ci_natural_sort',
)
# Override TagBase.slug to set db_collation
slug = models.SlugField(
verbose_name=pgettext_lazy("A tag slug", "slug"),
unique=True,
max_length=100,
allow_unicode=True,
db_collation='case_insensitive',
)
color = ColorField(
verbose_name=_('color'),
default=ColorChoices.COLOR_GREY

View File

@@ -3,7 +3,6 @@ import datetime
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
from django.utils.timezone import make_aware, now
from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
@@ -12,8 +11,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Loca
from extras.choices import *
from extras.models import *
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
from users.constants import TOKEN_PREFIX
from users.models import Group, Token, User
from users.models import Group, User
from utilities.testing import APITestCase, APIViewTestCases
@@ -856,47 +854,6 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
)
ConfigTemplate.objects.bulk_create(config_templates)
def test_render(self):
configtemplate = ConfigTemplate.objects.first()
self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], 'Foo: bar')
def test_render_without_permission(self):
configtemplate = ConfigTemplate.objects.first()
# No permissions added - user has no render permission
url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
response = self.client.post(url, {'foo': 'bar'}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
def test_render_token_write_enabled(self):
configtemplate = ConfigTemplate.objects.first()
self.add_permissions('extras.render_configtemplate', 'extras.view_configtemplate')
url = reverse('extras-api:configtemplate-render', kwargs={'pk': configtemplate.pk})
# Request without token auth should fail with PermissionDenied
response = self.client.post(url, {'foo': 'bar'}, format='json')
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Create token with write_enabled=False
token = Token.objects.create(version=2, user=self.user, write_enabled=False)
token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
# Request with write-disabled token should fail
response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Enable write and retry
token.write_enabled = True
token.save()
response = self.client.post(url, {'foo': 'bar'}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class ScriptTest(APITestCase):

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class IPAMQuery:
class IPAMQueryV1:
asn: ASNType = strawberry_django.field()
asn_list: List[ASNType] = strawberry_django.field()
@@ -61,3 +62,60 @@ class IPAMQuery:
vrf: VRFType = strawberry_django.field()
vrf_list: List[VRFType] = strawberry_django.field()
@strawberry.type(name="Query")
class IPAMQuery:
asn: ASNType = strawberry_django.field()
asn_list: OffsetPaginated[ASNType] = strawberry_django.offset_paginated()
asn_range: ASNRangeType = strawberry_django.field()
asn_range_list: OffsetPaginated[ASNRangeType] = strawberry_django.offset_paginated()
aggregate: AggregateType = strawberry_django.field()
aggregate_list: OffsetPaginated[AggregateType] = strawberry_django.offset_paginated()
ip_address: IPAddressType = strawberry_django.field()
ip_address_list: OffsetPaginated[IPAddressType] = strawberry_django.offset_paginated()
ip_range: IPRangeType = strawberry_django.field()
ip_range_list: OffsetPaginated[IPRangeType] = strawberry_django.offset_paginated()
prefix: PrefixType = strawberry_django.field()
prefix_list: OffsetPaginated[PrefixType] = strawberry_django.offset_paginated()
rir: RIRType = strawberry_django.field()
rir_list: OffsetPaginated[RIRType] = strawberry_django.offset_paginated()
role: RoleType = strawberry_django.field()
role_list: OffsetPaginated[RoleType] = strawberry_django.offset_paginated()
route_target: RouteTargetType = strawberry_django.field()
route_target_list: OffsetPaginated[RouteTargetType] = strawberry_django.offset_paginated()
service: ServiceType = strawberry_django.field()
service_list: OffsetPaginated[ServiceType] = strawberry_django.offset_paginated()
service_template: ServiceTemplateType = strawberry_django.field()
service_template_list: OffsetPaginated[ServiceTemplateType] = strawberry_django.offset_paginated()
fhrp_group: FHRPGroupType = strawberry_django.field()
fhrp_group_list: OffsetPaginated[FHRPGroupType] = strawberry_django.offset_paginated()
fhrp_group_assignment: FHRPGroupAssignmentType = strawberry_django.field()
fhrp_group_assignment_list: OffsetPaginated[FHRPGroupAssignmentType] = strawberry_django.offset_paginated()
vlan: VLANType = strawberry_django.field()
vlan_list: OffsetPaginated[VLANType] = strawberry_django.offset_paginated()
vlan_group: VLANGroupType = strawberry_django.field()
vlan_group_list: OffsetPaginated[VLANGroupType] = strawberry_django.offset_paginated()
vlan_translation_policy: VLANTranslationPolicyType = strawberry_django.field()
vlan_translation_policy_list: OffsetPaginated[VLANTranslationPolicyType] = strawberry_django.offset_paginated()
vlan_translation_rule: VLANTranslationRuleType = strawberry_django.field()
vlan_translation_rule_list: OffsetPaginated[VLANTranslationRuleType] = strawberry_django.offset_paginated()
vrf: VRFType = strawberry_django.field()
vrf_list: OffsetPaginated[VRFType] = strawberry_django.offset_paginated()

View File

@@ -1,100 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'ipam_asnrange_name_c7585e73_like',
'ipam_asnrange_slug_c8a7d8a1_like',
'ipam_rir_name_64a71982_like',
'ipam_rir_slug_ff1a369a_like',
'ipam_role_name_13784849_like',
'ipam_role_slug_309ca14c_like',
'ipam_routetarget_name_212be79f_like',
'ipam_servicetemplate_name_1a2f3410_like',
'ipam_vlangroup_slug_40abcf6b_like',
'ipam_vlantranslationpolicy_name_17e0a007_like',
'ipam_vrf_rd_0ac1bde1_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('ipam', '0082_add_prefix_network_containment_indexes'),
('dcim', '0217_ci_collations'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='asnrange',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='asnrange',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='rir',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='rir',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='role',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='role',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='routetarget',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=21, unique=True),
),
migrations.AlterField(
model_name='servicetemplate',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='vlan',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='vlangroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='vlangroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='vlantranslationpolicy',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='vrf',
name='rd',
field=models.CharField(blank=True, db_collation='case_insensitive', max_length=21, null=True, unique=True),
),
]

View File

@@ -18,7 +18,12 @@ class ASNRange(OrganizationalModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
)
rir = models.ForeignKey(
to='ipam.RIR',

View File

@@ -50,8 +50,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
class Meta:

View File

@@ -37,12 +37,11 @@ class VLANGroup(OrganizationalModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
db_collation='case_insensitive',
max_length=100
)
scope_type = models.ForeignKey(
to='contenttypes.ContentType',
@@ -215,8 +214,7 @@ class VLAN(PrimaryModel):
)
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
max_length=64
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -364,7 +362,6 @@ class VLANTranslationPolicy(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
)
class Meta:

View File

@@ -19,12 +19,11 @@ class VRF(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='natural_sort',
db_collation="natural_sort"
)
rd = models.CharField(
max_length=VRF_RD_MAX_LENGTH,
unique=True,
db_collation='case_insensitive',
blank=True,
null=True,
verbose_name=_('route distinguisher'),
@@ -76,8 +75,8 @@ class RouteTarget(PrimaryModel):
verbose_name=_('name'),
max_length=VRF_RD_MAX_LENGTH, # Same format options as VRF RD (RFC 4360 section 4)
unique=True,
db_collation='ci_natural_sort',
help_text=_('Route target value (formatted in accordance with RFC 4360)'),
db_collation="natural_sort"
)
tenant = models.ForeignKey(
to='tenancy.Tenant',

View File

@@ -164,20 +164,6 @@ class TokenPermissions(DjangoObjectPermissions):
return super().has_object_permission(request, view, obj)
class TokenWritePermission(BasePermission):
"""
Verify the token has write_enabled for unsafe methods, without requiring specific model permissions.
Used for custom actions that accept user data but don't map to standard CRUD operations.
"""
def has_permission(self, request, view):
if not isinstance(request.auth, Token):
raise exceptions.PermissionDenied(
"TokenWritePermission requires token authentication."
)
return bool(request.method in SAFE_METHODS or request.auth.write_enabled)
class IsAuthenticatedOrLoginNotRequired(BasePermission):
"""
Returns True if the user is authenticated or LOGIN_REQUIRED is False.

View File

@@ -4,17 +4,17 @@ from strawberry_django.optimizer import DjangoOptimizerExtension
from strawberry.extensions import MaxAliasesLimiter
from strawberry.schema.config import StrawberryConfig
from circuits.graphql.schema import CircuitsQuery
from core.graphql.schema import CoreQuery
from dcim.graphql.schema import DCIMQuery
from extras.graphql.schema import ExtrasQuery
from ipam.graphql.schema import IPAMQuery
from circuits.graphql.schema import CircuitsQuery, CircuitsQueryV1
from core.graphql.schema import CoreQuery, CoreQueryV1
from dcim.graphql.schema import DCIMQuery, DCIMQueryV1
from extras.graphql.schema import ExtrasQuery, ExtrasQueryV1
from ipam.graphql.schema import IPAMQuery, IPAMQueryV1
from netbox.registry import registry
from tenancy.graphql.schema import TenancyQuery
from users.graphql.schema import UsersQuery
from virtualization.graphql.schema import VirtualizationQuery
from vpn.graphql.schema import VPNQuery
from wireless.graphql.schema import WirelessQuery
from tenancy.graphql.schema import TenancyQuery, TenancyQueryV1
from users.graphql.schema import UsersQuery, UsersQueryV1
from virtualization.graphql.schema import VirtualizationQuery, VirtualizationQueryV1
from vpn.graphql.schema import VPNQuery, VPNQueryV1
from wireless.graphql.schema import WirelessQuery, WirelessQueryV1
__all__ = (
'Query',
@@ -27,16 +27,16 @@ __all__ = (
@strawberry.type
class QueryV1(
UsersQuery,
CircuitsQuery,
CoreQuery,
DCIMQuery,
ExtrasQuery,
IPAMQuery,
TenancyQuery,
VirtualizationQuery,
VPNQuery,
WirelessQuery,
UsersQueryV1,
CircuitsQueryV1,
CoreQueryV1,
DCIMQueryV1,
ExtrasQueryV1,
IPAMQueryV1,
TenancyQueryV1,
VirtualizationQueryV1,
VPNQueryV1,
WirelessQueryV1,
*registry['plugins']['graphql_schemas'], # Append plugin schemas
):
"""Query class for GraphQL API v1"""

View File

@@ -153,13 +153,11 @@ class NestedGroupModel(NetBoxFeatureSet, MPTTModel):
)
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
db_collation='case_insensitive',
max_length=100
)
description = models.CharField(
verbose_name=_('description'),
@@ -204,14 +202,12 @@ class OrganizationalModel(NetBoxModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
unique=True
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
description = models.CharField(
verbose_name=_('description'),

View File

@@ -46,9 +46,9 @@ class GraphQLTestCase(TestCase):
class GraphQLAPITestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
def test_graphql_filter_objects_v1(self):
"""
Test the operation of filters for GraphQL API requests.
Test the operation of filters for GraphQL API v1 requests (old format with List[Type]).
"""
sites = (
Site(name='Site 1', slug='site-1'),
@@ -85,7 +85,7 @@ class GraphQLAPITestCase(APITestCase):
obj_perm.object_types.add(ObjectType.objects.get_for_model(Location))
obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
url = reverse('graphql')
url = reverse('graphql_v1')
# A valid request should return the filtered list
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {id site {id}}}'
@@ -126,3 +126,91 @@ class GraphQLAPITestCase(APITestCase):
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data']['site']['locations']), 0)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
"""
Test the operation of filters for GraphQL API v2 requests (new format with OffsetPaginated).
"""
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
Location.objects.create(
site=sites[0],
name='Location 1',
slug='location-1',
status=LocationStatusChoices.STATUS_PLANNED
),
Location.objects.create(
site=sites[1],
name='Location 2',
slug='location-2',
status=LocationStatusChoices.STATUS_STAGING
),
Location.objects.create(
site=sites[1],
name='Location 3',
slug='location-3',
status=LocationStatusChoices.STATUS_ACTIVE
),
# 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))
obj_perm.object_types.add(ObjectType.objects.get_for_model(Site))
url = reverse('graphql_v2')
# A valid request should return the filtered list
query = '{location_list(filters: {site_id: "' + str(sites[0].pk) + '"}) {results {id site {id}} total_count}}'
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']['results']), 1)
self.assertEqual(data['data']['location_list']['total_count'], 1)
self.assertIsNotNone(data['data']['location_list']['results'][0]['site'])
# Test OR logic
query = """{
location_list( filters: {
status: STATUS_PLANNED,
OR: {status: STATUS_STAGING}
}) {
results {
id site {id}
}
total_count
}
}"""
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']['results']), 2)
self.assertEqual(data['data']['location_list']['total_count'], 2)
# An invalid request should return an empty list
query = '{location_list(filters: {site_id: "99999"}) {results {id site {id}} total_count}}' # 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']['results']), 0)
self.assertEqual(data['data']['location_list']['total_count'], 0)
# Removing the permissions from location should result in an empty locations list
obj_perm.object_types.remove(ObjectType.objects.get_for_model(Location))
query = '{site(id: ' + str(sites[0].pk) + ') {id locations {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']['site']['locations']), 0)

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class TenancyQuery:
class TenancyQueryV1:
tenant: TenantType = strawberry_django.field()
tenant_list: List[TenantType] = strawberry_django.field()
@@ -25,3 +26,24 @@ class TenancyQuery:
contact_assignment: ContactAssignmentType = strawberry_django.field()
contact_assignment_list: List[ContactAssignmentType] = strawberry_django.field()
@strawberry.type(name="Query")
class TenancyQuery:
tenant: TenantType = strawberry_django.field()
tenant_list: OffsetPaginated[TenantType] = strawberry_django.offset_paginated()
tenant_group: TenantGroupType = strawberry_django.field()
tenant_group_list: OffsetPaginated[TenantGroupType] = strawberry_django.offset_paginated()
contact: ContactType = strawberry_django.field()
contact_list: OffsetPaginated[ContactType] = strawberry_django.offset_paginated()
contact_role: ContactRoleType = strawberry_django.field()
contact_role_list: OffsetPaginated[ContactRoleType] = strawberry_django.offset_paginated()
contact_group: ContactGroupType = strawberry_django.field()
contact_group_list: OffsetPaginated[ContactGroupType] = strawberry_django.offset_paginated()
contact_assignment: ContactAssignmentType = strawberry_django.field()
contact_assignment_list: OffsetPaginated[ContactAssignmentType] = strawberry_django.offset_paginated()

View File

@@ -1,70 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'tenancy_contactgroup_slug_5b0f3e75_like',
'tenancy_contactrole_name_44b01a1f_like',
'tenancy_contactrole_slug_c5837d7d_like',
'tenancy_tenant_slug_0716575e_like',
'tenancy_tenantgroup_name_53363199_like',
'tenancy_tenantgroup_slug_e2af1cb6_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('tenancy', '0020_remove_contactgroupmembership'),
('dcim', '0217_ci_collations'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='contactgroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='contactgroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='contactrole',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='contactrole',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tenant',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='tenant',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100),
),
migrations.AlterField(
model_name='tenantgroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tenantgroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
]

View File

@@ -55,7 +55,7 @@ class Contact(PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='natural_sort',
db_collation="natural_sort"
)
title = models.CharField(
verbose_name=_('title'),

View File

@@ -19,13 +19,12 @@ class TenantGroup(NestedGroupModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort'
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive'
unique=True
)
class Meta:
@@ -42,12 +41,11 @@ class Tenant(ContactsMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
db_collation='case_insensitive',
max_length=100
)
group = models.ForeignKey(
to='tenancy.TenantGroup',

View File

@@ -2,14 +2,24 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class UsersQuery:
class UsersQueryV1:
group: GroupType = strawberry_django.field()
group_list: List[GroupType] = strawberry_django.field()
user: UserType = strawberry_django.field()
user_list: List[UserType] = strawberry_django.field()
@strawberry.type(name="Query")
class UsersQuery:
group: GroupType = strawberry_django.field()
group_list: OffsetPaginated[GroupType] = strawberry_django.offset_paginated()
user: UserType = strawberry_django.field()
user_list: OffsetPaginated[UserType] = strawberry_django.offset_paginated()

View File

@@ -515,10 +515,15 @@ class APIViewTestCases:
base_name = self.model._meta.verbose_name.lower().replace(' ', '_')
return getattr(self, 'graphql_base_name', base_name)
def _build_query_with_filter(self, name, filter_string):
def _build_query_with_filter(self, name, filter_string, api_version='v2'):
"""
Called by either _build_query or _build_filtered_query - construct the actual
query given a name and filter string
Args:
name: The query field name (e.g., 'device_list')
filter_string: Filter parameters string (e.g., '(filters: {id: "1"})')
api_version: 'v1' or 'v2' to determine response format
"""
type_class = get_graphql_type_for_model(self.model)
@@ -562,19 +567,48 @@ class APIViewTestCases:
else:
fields_string += f'{field.name}\n'
query = f"""
{{
{name}{filter_string} {{
{fields_string}
# Check if this is a list query (ends with '_list')
if name.endswith('_list'):
if api_version == 'v2':
# v2: Wrap fields in 'results' for paginated queries
query = f"""
{{
{name}{filter_string} {{
results {{
{fields_string}
}}
}}
}}
"""
else:
# v1: Return direct array (no 'results' wrapper)
query = f"""
{{
{name}{filter_string} {{
{fields_string}
}}
}}
"""
else:
# Single object query (no pagination)
query = f"""
{{
{name}{filter_string} {{
{fields_string}
}}
}}
}}
"""
"""
return query
def _build_filtered_query(self, name, **filters):
def _build_filtered_query(self, name, api_version='v2', **filters):
"""
Create a filtered query: i.e. device_list(filters: {name: {i_contains: "akron"}}){.
Args:
name: The query field name
api_version: 'v1' or 'v2' to determine response format
**filters: Filter parameters
"""
# TODO: This should be extended to support AND, OR multi-lookups
if filters:
@@ -590,11 +624,16 @@ class APIViewTestCases:
else:
filter_string = ''
return self._build_query_with_filter(name, filter_string)
return self._build_query_with_filter(name, filter_string, api_version)
def _build_query(self, name, **filters):
def _build_query(self, name, api_version='v2', **filters):
"""
Create a normal query - unfiltered or with a string query: i.e. site(name: "aaa"){.
Args:
name: The query field name
api_version: 'v1' or 'v2' to determine response format
**filters: Filter parameters
"""
if filters:
filter_string = ', '.join(f'{k}:{v}' for k, v in filters.items())
@@ -602,7 +641,7 @@ class APIViewTestCases:
else:
filter_string = ''
return self._build_query_with_filter(name, filter_string)
return self._build_query_with_filter(name, filter_string, api_version)
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_get_object(self):
@@ -650,54 +689,71 @@ class APIViewTestCases:
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_list_objects(self):
url = reverse('graphql')
field_name = f'{self._get_graphql_base_name()}_list'
query = self._build_query(field_name)
# Non-authenticated requests should fail
header = {
'HTTP_ACCEPT': 'application/json',
}
with disable_warnings('django.request'):
response = self.client.post(url, data={'query': query}, format="json", **header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Test both GraphQL API versions
for api_version, url_name in [('v1', 'graphql_v1'), ('v2', 'graphql_v2')]:
with self.subTest(api_version=api_version):
url = reverse(url_name)
query = self._build_query(field_name, api_version=api_version)
# Add constrained permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view'],
constraints={'id': 0} # Impossible constraint
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Non-authenticated requests should fail
header = {
'HTTP_ACCEPT': 'application/json',
}
with disable_warnings('django.request'):
response = self.client.post(url, data={'query': query}, format="json", **header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Request should succeed but return empty results list
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertEqual(len(data['data'][field_name]), 0)
# Add constrained permission
obj_perm = ObjectPermission(
name='Test permission',
actions=['view'],
constraints={'id': 0} # Impossible constraint
)
obj_perm.save()
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
# Remove permission constraint
obj_perm.constraints = None
obj_perm.save()
# Request should succeed but return empty results list
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
# 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())
if api_version == 'v1':
# v1 returns direct array
self.assertEqual(len(data['data'][field_name]), 0)
else:
# v2 returns paginated response with results
self.assertEqual(len(data['data'][field_name]['results']), 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)
if api_version == 'v1':
# v1 returns direct array
self.assertEqual(len(data['data'][field_name]), self.model.objects.count())
else:
# v2 returns paginated response with results
self.assertEqual(len(data['data'][field_name]['results']), self.model.objects.count())
# Clean up permission for next iteration
obj_perm.delete()
@override_settings(LOGIN_REQUIRED=True)
def test_graphql_filter_objects(self):
if not hasattr(self, 'graphql_filter'):
return
url = reverse('graphql')
field_name = f'{self._get_graphql_base_name()}_list'
query = self._build_filtered_query(field_name, **self.graphql_filter)
# Add object-level permission
obj_perm = ObjectPermission(
@@ -708,11 +764,26 @@ class APIViewTestCases:
obj_perm.users.add(self.user)
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
response = self.client.post(url, data={'query': query}, format="json", **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
data = json.loads(response.content)
self.assertNotIn('errors', data)
self.assertGreater(len(data['data'][field_name]), 0)
# Test both GraphQL API versions
for api_version, url_name in [('v1', 'graphql_v1'), ('v2', 'graphql_v2')]:
with self.subTest(api_version=api_version):
url = reverse(url_name)
query = self._build_filtered_query(field_name, api_version=api_version, **self.graphql_filter)
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)
if api_version == 'v1':
# v1 returns direct array
self.assertGreater(len(data['data'][field_name]), 0)
else:
# v2 returns paginated response with results
self.assertGreater(len(data['data'][field_name]['results']), 0)
# Clean up permission
obj_perm.delete()
class APIViewTestCase(
GetObjectViewTestCase,

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class VirtualizationQuery:
class VirtualizationQueryV1:
cluster: ClusterType = strawberry_django.field()
cluster_list: List[ClusterType] = strawberry_django.field()
@@ -25,3 +26,24 @@ class VirtualizationQuery:
virtual_disk: VirtualDiskType = strawberry_django.field()
virtual_disk_list: List[VirtualDiskType] = strawberry_django.field()
@strawberry.type(name="Query")
class VirtualizationQuery:
cluster: ClusterType = strawberry_django.field()
cluster_list: OffsetPaginated[ClusterType] = strawberry_django.offset_paginated()
cluster_group: ClusterGroupType = strawberry_django.field()
cluster_group_list: OffsetPaginated[ClusterGroupType] = strawberry_django.offset_paginated()
cluster_type: ClusterTypeType = strawberry_django.field()
cluster_type_list: OffsetPaginated[ClusterTypeType] = strawberry_django.offset_paginated()
virtual_machine: VirtualMachineType = strawberry_django.field()
virtual_machine_list: OffsetPaginated[VirtualMachineType] = strawberry_django.offset_paginated()
vm_interface: VMInterfaceType = strawberry_django.field()
vm_interface_list: OffsetPaginated[VMInterfaceType] = strawberry_django.offset_paginated()
virtual_disk: VirtualDiskType = strawberry_django.field()
virtual_disk_list: OffsetPaginated[VirtualDiskType] = strawberry_django.offset_paginated()

View File

@@ -1,92 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'virtualization_clustergroup_name_4fcd26b4_like',
'virtualization_clustergroup_slug_57ca1d23_like',
'virtualization_clustertype_name_ea854d3d_like',
'virtualization_clustertype_slug_8ee4d0e0_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0217_ci_collations'),
('extras', '0134_ci_collations'),
('ipam', '0083_ci_collations'),
('tenancy', '0021_ci_collations'),
('virtualization', '0048_populate_mac_addresses'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.RemoveConstraint(
model_name='virtualmachine',
name='virtualization_virtualmachine_unique_name_cluster_tenant',
),
migrations.RemoveConstraint(
model_name='virtualmachine',
name='virtualization_virtualmachine_unique_name_cluster',
),
migrations.AlterField(
model_name='cluster',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100),
),
migrations.AlterField(
model_name='clustergroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='clustergroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='clustertype',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='clustertype',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='virtualdisk',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AlterField(
model_name='virtualmachine',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=64),
),
migrations.AddConstraint(
model_name='virtualmachine',
constraint=models.UniqueConstraint(
models.F('name'),
models.F('cluster'),
models.F('tenant'),
name='virtualization_virtualmachine_unique_name_cluster_tenant',
),
),
migrations.AddConstraint(
model_name='virtualmachine',
constraint=models.UniqueConstraint(
models.F('name'),
models.F('cluster'),
condition=models.Q(('tenant__isnull', True)),
name='virtualization_virtualmachine_unique_name_cluster',
violation_error_message='Virtual machine name must be unique per cluster.',
),
),
]

View File

@@ -51,7 +51,7 @@ class Cluster(ContactsMixin, CachedScopeMixin, PrimaryModel):
name = models.CharField(
verbose_name=_('name'),
max_length=100,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
type = models.ForeignKey(
verbose_name=_('type'),

View File

@@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.db.models import Q, Sum
from django.db.models.functions import Lower
from django.utils.translation import gettext_lazy as _
from dcim.models import BaseInterface
@@ -69,7 +70,7 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
status = models.CharField(
max_length=50,
@@ -155,11 +156,11 @@ class VirtualMachine(ContactsMixin, ImageAttachmentsMixin, RenderConfigMixin, Co
ordering = ('name', 'pk') # Name may be non-unique
constraints = (
models.UniqueConstraint(
'name', 'cluster', 'tenant',
Lower('name'), 'cluster', 'tenant',
name='%(app_label)s_%(class)s_unique_name_cluster_tenant'
),
models.UniqueConstraint(
'name', 'cluster',
Lower('name'), 'cluster',
name='%(app_label)s_%(class)s_unique_name_cluster',
condition=Q(tenant__isnull=True),
violation_error_message=_("Virtual machine name must be unique per cluster.")
@@ -274,7 +275,7 @@ class ComponentModel(NetBoxModel):
name = models.CharField(
verbose_name=_('name'),
max_length=64,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
description = models.CharField(
verbose_name=_('description'),

View File

@@ -12,8 +12,6 @@ from extras.choices import CustomFieldTypeChoices
from extras.models import ConfigTemplate, CustomField
from ipam.choices import VLANQinQRoleChoices
from ipam.models import Prefix, VLAN, VRF
from users.constants import TOKEN_PREFIX
from users.models import Token
from utilities.testing import (
APITestCase, APIViewTestCases, create_test_device, create_test_virtualmachine, disable_logging,
)
@@ -283,60 +281,12 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
vm.config_template = configtemplate
vm.save()
self.add_permissions(
'virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine'
)
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
self.add_permissions('virtualization.add_virtualmachine')
url = reverse('virtualization-api:virtualmachine-detail', kwargs={'pk': vm.pk}) + 'render-config/'
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['content'], f'Config for virtual machine {vm.name}')
def test_render_config_without_permission(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for virtual machine {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = configtemplate
vm.save()
# No permissions added - user has no render_config permission
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
response = self.client.post(url, {}, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_404_NOT_FOUND)
def test_render_config_token_write_enabled(self):
configtemplate = ConfigTemplate.objects.create(
name='Config Template 1',
template_code='Config for virtual machine {{ virtualmachine.name }}'
)
vm = VirtualMachine.objects.first()
vm.config_template = configtemplate
vm.save()
self.add_permissions('virtualization.render_config_virtualmachine', 'virtualization.view_virtualmachine')
url = reverse('virtualization-api:virtualmachine-render-config', kwargs={'pk': vm.pk})
# Request without token auth should fail with PermissionDenied
response = self.client.post(url, {}, format='json')
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Create token with write_enabled=False
token = Token.objects.create(version=2, user=self.user, write_enabled=False)
token_header = f'Bearer {TOKEN_PREFIX}{token.key}.{token.token}'
# Request with write-disabled token should fail
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_403_FORBIDDEN)
# Enable write and retry
token.write_enabled = True
token.save()
response = self.client.post(url, {}, format='json', HTTP_AUTHORIZATION=token_header)
self.assertHttpStatus(response, status.HTTP_200_OK)
class VMInterfaceTest(APIViewTestCases.APIViewTestCase):
model = VMInterface

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class VPNQuery:
class VPNQueryV1:
ike_policy: IKEPolicyType = strawberry_django.field()
ike_policy_list: List[IKEPolicyType] = strawberry_django.field()
@@ -37,3 +38,36 @@ class VPNQuery:
tunnel_termination: TunnelTerminationType = strawberry_django.field()
tunnel_termination_list: List[TunnelTerminationType] = strawberry_django.field()
@strawberry.type(name="Query")
class VPNQuery:
ike_policy: IKEPolicyType = strawberry_django.field()
ike_policy_list: OffsetPaginated[IKEPolicyType] = strawberry_django.offset_paginated()
ike_proposal: IKEProposalType = strawberry_django.field()
ike_proposal_list: OffsetPaginated[IKEProposalType] = strawberry_django.offset_paginated()
ipsec_policy: IPSecPolicyType = strawberry_django.field()
ipsec_policy_list: OffsetPaginated[IPSecPolicyType] = strawberry_django.offset_paginated()
ipsec_profile: IPSecProfileType = strawberry_django.field()
ipsec_profile_list: OffsetPaginated[IPSecProfileType] = strawberry_django.offset_paginated()
ipsec_proposal: IPSecProposalType = strawberry_django.field()
ipsec_proposal_list: OffsetPaginated[IPSecProposalType] = strawberry_django.offset_paginated()
l2vpn: L2VPNType = strawberry_django.field()
l2vpn_list: OffsetPaginated[L2VPNType] = strawberry_django.offset_paginated()
l2vpn_termination: L2VPNTerminationType = strawberry_django.field()
l2vpn_termination_list: OffsetPaginated[L2VPNTerminationType] = strawberry_django.offset_paginated()
tunnel: TunnelType = strawberry_django.field()
tunnel_list: OffsetPaginated[TunnelType] = strawberry_django.offset_paginated()
tunnel_group: TunnelGroupType = strawberry_django.field()
tunnel_group_list: OffsetPaginated[TunnelGroupType] = strawberry_django.offset_paginated()
tunnel_termination: TunnelTerminationType = strawberry_django.field()
tunnel_termination_list: OffsetPaginated[TunnelTerminationType] = strawberry_django.offset_paginated()

View File

@@ -1,84 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'vpn_ikepolicy_name_5124aa3b_like',
'vpn_ikeproposal_name_254623b7_like',
'vpn_ipsecpolicy_name_cf28a1aa_like',
'vpn_ipsecprofile_name_3ac63c72_like',
'vpn_ipsecproposal_name_2fb98e2b_like',
'vpn_l2vpn_name_8824eda5_like',
'vpn_l2vpn_slug_76b5a174_like',
'vpn_tunnel_name_f060beab_like',
'vpn_tunnelgroup_name_9f6ebf92_like',
'vpn_tunnelgroup_slug_9e614d62_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0217_ci_collations'),
('vpn', '0009_remove_redundant_indexes'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='ikepolicy',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ikeproposal',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ipsecpolicy',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ipsecprofile',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='ipsecproposal',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='l2vpn',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='l2vpn',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tunnel',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tunnelgroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='tunnelgroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
]

View File

@@ -23,7 +23,7 @@ class IKEProposal(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
authentication_method = models.CharField(
verbose_name=('authentication method'),
@@ -69,7 +69,7 @@ class IKEPolicy(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
version = models.PositiveSmallIntegerField(
verbose_name=_('version'),
@@ -128,7 +128,7 @@ class IPSecProposal(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
encryption_algorithm = models.CharField(
verbose_name=_('encryption'),
@@ -180,7 +180,7 @@ class IPSecPolicy(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
proposals = models.ManyToManyField(
to='vpn.IPSecProposal',
@@ -216,7 +216,7 @@ class IPSecProfile(PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
mode = models.CharField(
verbose_name=_('mode'),

View File

@@ -20,13 +20,12 @@ class L2VPN(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
type = models.CharField(
verbose_name=_('type'),

View File

@@ -32,7 +32,7 @@ class Tunnel(ContactsMixin, PrimaryModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
status = models.CharField(
verbose_name=_('status'),

View File

@@ -2,12 +2,13 @@ from typing import List
import strawberry
import strawberry_django
from strawberry_django.pagination import OffsetPaginated
from .types import *
@strawberry.type(name="Query")
class WirelessQuery:
class WirelessQueryV1:
wireless_lan: WirelessLANType = strawberry_django.field()
wireless_lan_list: List[WirelessLANType] = strawberry_django.field()
@@ -16,3 +17,15 @@ class WirelessQuery:
wireless_link: WirelessLinkType = strawberry_django.field()
wireless_link_list: List[WirelessLinkType] = strawberry_django.field()
@strawberry.type(name="Query")
class WirelessQuery:
wireless_lan: WirelessLANType = strawberry_django.field()
wireless_lan_list: OffsetPaginated[WirelessLANType] = strawberry_django.offset_paginated()
wireless_lan_group: WirelessLANGroupType = strawberry_django.field()
wireless_lan_group_list: OffsetPaginated[WirelessLANGroupType] = strawberry_django.offset_paginated()
wireless_link: WirelessLinkType = strawberry_django.field()
wireless_link_list: OffsetPaginated[WirelessLinkType] = strawberry_django.offset_paginated()

View File

@@ -1,36 +0,0 @@
from django.db import migrations, models
PATTERN_OPS_INDEXES = [
'wireless_wirelesslangroup_name_2ffd60c8_like',
'wireless_wirelesslangroup_slug_f5d59831_like',
]
def remove_indexes(apps, schema_editor):
for idx in PATTERN_OPS_INDEXES:
schema_editor.execute(f'DROP INDEX IF EXISTS {idx}')
class Migration(migrations.Migration):
dependencies = [
('dcim', '0217_ci_collations'),
('wireless', '0015_extend_wireless_link_abs_distance_upper_limit'),
]
operations = [
migrations.RunPython(
code=remove_indexes,
reverse_code=migrations.RunPython.noop,
),
migrations.AlterField(
model_name='wirelesslangroup',
name='name',
field=models.CharField(db_collation='ci_natural_sort', max_length=100, unique=True),
),
migrations.AlterField(
model_name='wirelesslangroup',
name='slug',
field=models.SlugField(db_collation='case_insensitive', max_length=100, unique=True),
),
]

View File

@@ -53,13 +53,12 @@ class WirelessLANGroup(NestedGroupModel):
verbose_name=_('name'),
max_length=100,
unique=True,
db_collation='ci_natural_sort',
db_collation="natural_sort"
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True,
db_collation='case_insensitive',
unique=True
)
class Meta: