mirror of
https://github.com/netbox-community/netbox.git
synced 2026-02-14 21:07:41 +01:00
Compare commits
11 Commits
12318-case
...
19724-grap
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76caae12fa | ||
|
|
26c91f01c6 | ||
|
|
af55da008b | ||
|
|
810d1c2418 | ||
|
|
91b2d61ea4 | ||
|
|
b7b7b00885 | ||
|
|
595b343cd0 | ||
|
|
730aee9b26 | ||
|
|
8aa1e2802b | ||
|
|
c2d19119cb | ||
|
|
0c4d0fa2e8 |
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
),
|
||||
]
|
||||
@@ -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.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user