Compare commits
20 Commits
20a56ffb04
...
bfa55efab6
Author | SHA1 | Date |
---|---|---|
Daniel Sheppard | bfa55efab6 | |
Markku Leiniö | 0cc2963e6f | |
Arthur Hanson | 56ea7b8714 | |
Jeremy Stretch | 6e658d43dc | |
Arnold | 6ff349dbac | |
Daniel Sheppard | d7d97b1b52 | |
Markku Leiniö | d7f652bcc7 | |
Jeremy Stretch | 313b6e624c | |
Arthur | 0df3787796 | |
Jeremy Stretch | 5c68fc9202 | |
Jeremy Stretch | ff8dabe8d9 | |
Markku Leiniö | 5c5c0e1e43 | |
Jeremy Stretch | b87d1eca98 | |
teapot | db823634cf | |
Jeremy Stretch | 195dbaed00 | |
Jeremy Stretch | a9a012daf0 | |
Jeremy Stretch | 4d40699f2c | |
Daniel Sheppard | adca617369 | |
Daniel Sheppard | c596194387 | |
Daniel Sheppard | 7600adc1e1 |
|
@ -11,8 +11,21 @@ master = true
|
|||
; clear environment on exit
|
||||
vacuum = true
|
||||
|
||||
; make SIGTERM stop the app (instead of reload)
|
||||
die-on-term = true
|
||||
|
||||
; exit if no app can be loaded
|
||||
need-app = true
|
||||
|
||||
; do not use multiple interpreters
|
||||
single-interpreter = true
|
||||
|
||||
; change to the project directory
|
||||
chdir = netbox
|
||||
|
||||
; specify the WSGI module to load
|
||||
module = netbox.wsgi
|
||||
|
||||
; only log internal messages and errors (reverse proxy already logs the requests)
|
||||
disable-logging = true
|
||||
log-5xx = true
|
||||
|
|
|
@ -77,7 +77,7 @@ Create the following for each model:
|
|||
|
||||
## 13. GraphQL API components
|
||||
|
||||
Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
|
||||
|
||||
Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention.
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ pip3 install pyuwsgi
|
|||
Once installed, add the package to `local_requirements.txt` to ensure it is re-installed during future rebuilds of the virtual environment:
|
||||
|
||||
```no-highlight
|
||||
sudo sh -c "echo 'pyuwgsi' >> /opt/netbox/local_requirements.txt"
|
||||
sudo sh -c "echo 'pyuwsgi' >> /opt/netbox/local_requirements.txt"
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
# GraphQL API Overview
|
||||
|
||||
NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by the [Graphene](https://graphene-python.org/) library and [Graphene-Django](https://docs.graphene-python.org/projects/django/en/latest/).
|
||||
NetBox provides a read-only [GraphQL](https://graphql.org/) API to complement its REST API. This API is powered by [Strawberry Django](https://strawberry-graphql.github.io/strawberry-django/).
|
||||
|
||||
## Queries
|
||||
|
||||
|
@ -47,7 +47,7 @@ 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.
|
||||
|
||||
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/) as well as the [GraphQL queries documentation](https://graphql.org/learn/queries/).
|
||||
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-graphql.github.io/strawberry-django/guide/filters/).
|
||||
|
||||
## Filtering
|
||||
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
## Defining the Schema Class
|
||||
|
||||
A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class. This class must be a subclass of `graphene.ObjectType`.
|
||||
A plugin can extend NetBox's GraphQL API by registering its own schema class. By default, NetBox will attempt to import `graphql.schema` from the plugin, if it exists. This path can be overridden by defining `graphql_schema` on the PluginConfig instance as the dotted path to the desired Python class.
|
||||
|
||||
### Example
|
||||
|
||||
|
|
|
@ -48,7 +48,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
|||
class CircuitSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
|
||||
provider = ProviderSerializer(nested=True)
|
||||
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True)
|
||||
provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
status = ChoiceField(choices=CircuitStatusChoices, required=False)
|
||||
type = CircuitTypeSerializer(nested=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
|
|
|
@ -45,6 +45,7 @@ class ProviderSerializer(NetBoxModelSerializer):
|
|||
class ProviderAccountSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:provideraccount-detail')
|
||||
provider = ProviderSerializer(nested=True)
|
||||
name = serializers.CharField(allow_blank=True, max_length=100, required=False, default='')
|
||||
|
||||
class Meta:
|
||||
model = ProviderAccount
|
||||
|
|
|
@ -141,7 +141,7 @@ class CircuitTest(APIViewTestCases.APIViewTestCase):
|
|||
{
|
||||
'cid': 'Circuit 6',
|
||||
'provider': providers[1].pk,
|
||||
'provider_account': provider_accounts[1].pk,
|
||||
# Omit provider account to test uniqueness constraint
|
||||
'type': circuit_types[1].pk,
|
||||
},
|
||||
]
|
||||
|
@ -237,7 +237,7 @@ class ProviderAccountTest(APIViewTestCases.APIViewTestCase):
|
|||
'account': '5678',
|
||||
},
|
||||
{
|
||||
'name': 'Provider Account 6',
|
||||
# Omit name to test uniqueness constraint
|
||||
'provider': providers[0].pk,
|
||||
'account': '6789',
|
||||
},
|
||||
|
|
|
@ -122,6 +122,7 @@ class DeviceWithConfigContextSerializer(DeviceSerializer):
|
|||
class VirtualDeviceContextSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:virtualdevicecontext-detail')
|
||||
device = DeviceSerializer(nested=True)
|
||||
identifier = serializers.IntegerField(allow_null=True, max_value=32767, min_value=0, required=False, default=None)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
|
||||
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
|
|
|
@ -51,7 +51,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
|||
status = ChoiceField(choices=SiteStatusChoices, required=False)
|
||||
region = RegionSerializer(nested=True, required=False, allow_null=True)
|
||||
group = SiteGroupSerializer(nested=True, required=False, allow_null=True)
|
||||
tenant = TenantSerializer(required=False, allow_null=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
time_zone = TimeZoneSerializerField(required=False, allow_null=True)
|
||||
asns = SerializedPKRelatedField(
|
||||
queryset=ASN.objects.all(),
|
||||
|
@ -83,7 +83,7 @@ class SiteSerializer(NetBoxModelSerializer):
|
|||
class LocationSerializer(NestedGroupModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:location-detail')
|
||||
site = SiteSerializer(nested=True)
|
||||
parent = NestedLocationSerializer(required=False, allow_null=True)
|
||||
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
||||
status = ChoiceField(choices=LocationStatusChoices, required=False)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
rack_count = serializers.IntegerField(read_only=True)
|
||||
|
|
|
@ -10,6 +10,7 @@ from dcim.models import *
|
|||
from extras.models import ConfigTemplate
|
||||
from ipam.models import ASN, RIR, VLAN, VRF
|
||||
from netbox.api.serializers import GenericObjectSerializer
|
||||
from tenancy.models import Tenant
|
||||
from utilities.testing import APITestCase, APIViewTestCases, create_test_device
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
from wireless.choices import WirelessChannelChoices
|
||||
|
@ -152,6 +153,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
|||
Site.objects.bulk_create(sites)
|
||||
|
||||
rir = RIR.objects.create(name='RFC 6996', is_private=True)
|
||||
tenant = Tenant.objects.create(name='Tenant 1', slug='tenant-1')
|
||||
|
||||
asns = [
|
||||
ASN(asn=65000 + i, rir=rir) for i in range(8)
|
||||
|
@ -166,6 +168,7 @@ class SiteTest(APIViewTestCases.APIViewTestCase):
|
|||
'group': groups[1].pk,
|
||||
'status': SiteStatusChoices.STATUS_ACTIVE,
|
||||
'asns': [asns[0].pk, asns[1].pk],
|
||||
'tenant': tenant.pk,
|
||||
},
|
||||
{
|
||||
'name': 'Site 5',
|
||||
|
@ -230,7 +233,7 @@ class LocationTest(APIViewTestCases.APIViewTestCase):
|
|||
'name': 'Test Location 6',
|
||||
'slug': 'test-location-6',
|
||||
'site': sites[1].pk,
|
||||
'parent': parent_locations[1].pk,
|
||||
# Omit parent to test uniqueness constraint
|
||||
'status': LocationStatusChoices.STATUS_PLANNED,
|
||||
},
|
||||
]
|
||||
|
@ -1277,7 +1280,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
|
|||
device.config_template = configtemplate
|
||||
device.save()
|
||||
|
||||
self.add_permissions('dcim.add_device')
|
||||
self.add_permissions('dcim.view_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)
|
||||
|
@ -2307,6 +2310,6 @@ class VirtualDeviceContextTest(APIViewTestCases.APIViewTestCase):
|
|||
'device': devices[1].pk,
|
||||
'status': 'active',
|
||||
'name': 'VDC 3',
|
||||
'identifier': 3,
|
||||
# Omit identifier to test uniqueness constraint
|
||||
},
|
||||
]
|
||||
|
|
|
@ -4,6 +4,8 @@ from rest_framework.renderers import JSONRenderer
|
|||
from rest_framework.response import Response
|
||||
from rest_framework.status import HTTP_400_BAD_REQUEST
|
||||
|
||||
from dcim.models import Device
|
||||
from netbox.api.authentication import ViewOnlyPermissions
|
||||
from netbox.api.renderers import TextRenderer
|
||||
from .serializers import ConfigTemplateSerializer
|
||||
|
||||
|
@ -61,14 +63,23 @@ class ConfigTemplateRenderMixin:
|
|||
|
||||
|
||||
class RenderConfigMixin(ConfigTemplateRenderMixin):
|
||||
"""
|
||||
Override initial() to save a copy of the queryset for "un-restricting" the queryset when rendering.
|
||||
"""
|
||||
def initial(self, request, *args, **kwargs):
|
||||
self.original_queryset = self.queryset
|
||||
super().initial(request, *args, **kwargs)
|
||||
|
||||
"""
|
||||
Provides a /render-config/ endpoint for REST API views whose model may have a ConfigTemplate assigned.
|
||||
"""
|
||||
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer])
|
||||
@action(detail=True, methods=['post'], url_path='render-config', renderer_classes=[JSONRenderer, TextRenderer],
|
||||
permission_classes=[ViewOnlyPermissions])
|
||||
def render_config(self, request, pk):
|
||||
"""
|
||||
Resolve and render the preferred ConfigTemplate for this Device.
|
||||
"""
|
||||
self.queryset = self.original_queryset.restrict(request.user, 'view')
|
||||
instance = self.get_object()
|
||||
object_type = instance._meta.model_name
|
||||
configtemplate = instance.get_config_template()
|
||||
|
|
|
@ -85,6 +85,7 @@ class Command(BaseCommand):
|
|||
|
||||
module_name, script_name = script.split('.', 1)
|
||||
module, script = get_module_and_script(module_name, script_name)
|
||||
script = script.python_class
|
||||
|
||||
# Take user from command line if provided and exists, other
|
||||
if options['user']:
|
||||
|
|
|
@ -60,7 +60,10 @@ def get_module_scripts(scriptmodule):
|
|||
return cls.full_name.split(".", maxsplit=1)[1]
|
||||
|
||||
loader = SourceFileLoader(get_python_name(scriptmodule), get_full_path(scriptmodule))
|
||||
module = loader.load_module()
|
||||
try:
|
||||
module = loader.load_module()
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
|
||||
scripts = {}
|
||||
ordered = getattr(module, 'script_order', [])
|
||||
|
|
|
@ -545,7 +545,7 @@ class ScriptResultsTable(BaseTable):
|
|||
template_code="""{% load log_levels %}{% log_level record.status %}""",
|
||||
verbose_name=_('Level')
|
||||
)
|
||||
message = tables.Column(
|
||||
message = columns.MarkdownColumn(
|
||||
verbose_name=_('Message')
|
||||
)
|
||||
|
||||
|
@ -566,22 +566,17 @@ class ReportResultsTable(BaseTable):
|
|||
time = tables.Column(
|
||||
verbose_name=_('Time')
|
||||
)
|
||||
status = tables.Column(
|
||||
empty_values=(),
|
||||
verbose_name=_('Level')
|
||||
)
|
||||
status = tables.TemplateColumn(
|
||||
template_code="""{% load log_levels %}{% log_level record.status %}""",
|
||||
verbose_name=_('Level')
|
||||
)
|
||||
|
||||
object = tables.Column(
|
||||
verbose_name=_('Object')
|
||||
)
|
||||
url = tables.Column(
|
||||
verbose_name=_('URL')
|
||||
)
|
||||
message = tables.Column(
|
||||
message = columns.MarkdownColumn(
|
||||
verbose_name=_('Message')
|
||||
)
|
||||
|
||||
|
|
|
@ -574,7 +574,7 @@ class IPRange(PrimaryModel):
|
|||
if not self.end_address > self.start_address:
|
||||
raise ValidationError({
|
||||
'end_address': _(
|
||||
"Ending address must be lower than the starting address ({start_address})"
|
||||
"Ending address must be greater than the starting address ({start_address})"
|
||||
).format(start_address=self.start_address)
|
||||
})
|
||||
|
||||
|
|
|
@ -124,6 +124,21 @@ class TokenPermissions(DjangoObjectPermissions):
|
|||
return super().has_object_permission(request, view, obj)
|
||||
|
||||
|
||||
class ViewOnlyPermissions(TokenPermissions):
|
||||
"""
|
||||
Override the stock perm_map to require only view permissions
|
||||
"""
|
||||
perms_map = {
|
||||
'GET': ['%(app_label)s.view_%(model_name)s'],
|
||||
'OPTIONS': [],
|
||||
'HEAD': ['%(app_label)s.view_%(model_name)s'],
|
||||
'POST': ['%(app_label)s.view_%(model_name)s'],
|
||||
'PUT': ['%(app_label)s.view_%(model_name)s'],
|
||||
'PATCH': ['%(app_label)s.view_%(model_name)s'],
|
||||
'DELETE': ['%(app_label)s.view_%(model_name)s'],
|
||||
}
|
||||
|
||||
|
||||
class IsAuthenticatedOrLoginNotRequired(BasePermission):
|
||||
"""
|
||||
Returns True if the user is authenticated or LOGIN_REQUIRED is False.
|
||||
|
|
|
@ -31,6 +31,10 @@
|
|||
<th scope="row">{% trans "NetBox version" %}</th>
|
||||
<td>{{ stats.netbox_version }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Python version" %}</th>
|
||||
<td>{{ stats.python_version }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Django version" %}</th>
|
||||
<td>{{ stats.django_version }}</td>
|
||||
|
|
|
@ -46,7 +46,21 @@
|
|||
<input type="hidden" name="next" value="{{ request.POST.next }}" />
|
||||
{% endif %}
|
||||
|
||||
{% render_form form %}
|
||||
<div class="form-group mb-3">
|
||||
<label for="id_username" class="form-label">{{ form.username.label }}</label>
|
||||
{{ form.username }}
|
||||
{% for error in form.username.errors %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="id_password" class="form-label">{{ form.password.label }}</label>
|
||||
{{ form.password }}
|
||||
{% for error in form.password.errors %}
|
||||
<div class="alert alert-danger">{{ error }}</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
|
|
|
@ -27,7 +27,7 @@ class TenantGroupSerializer(NestedGroupModelSerializer):
|
|||
|
||||
class TenantSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenant-detail')
|
||||
group = TenantGroupSerializer(nested=True, required=False, allow_null=True)
|
||||
group = TenantGroupSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
|
||||
# Related object counts
|
||||
circuit_count = RelatedObjectCountField('circuits')
|
||||
|
|
|
@ -31,11 +31,11 @@ __all__ = (
|
|||
class VirtualMachineSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:virtualmachine-detail')
|
||||
status = ChoiceField(choices=VirtualMachineStatusChoices, required=False)
|
||||
site = SiteSerializer(nested=True, required=False, allow_null=True)
|
||||
cluster = ClusterSerializer(nested=True, required=False, allow_null=True)
|
||||
device = DeviceSerializer(nested=True, required=False, allow_null=True)
|
||||
site = SiteSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
cluster = ClusterSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
device = DeviceSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
role = DeviceRoleSerializer(nested=True, required=False, allow_null=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True, default=None)
|
||||
platform = PlatformSerializer(nested=True, required=False, allow_null=True)
|
||||
primary_ip = IPAddressSerializer(nested=True, read_only=True, allow_null=True)
|
||||
primary_ip4 = IPAddressSerializer(nested=True, required=False, allow_null=True)
|
||||
|
@ -55,7 +55,6 @@ class VirtualMachineSerializer(NetBoxModelSerializer):
|
|||
'interface_count', 'virtual_disk_count',
|
||||
]
|
||||
brief_fields = ('id', 'url', 'display', 'name', 'description')
|
||||
validators = []
|
||||
|
||||
|
||||
class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
|
||||
|
|
|
@ -239,7 +239,7 @@ class VirtualMachineTest(APIViewTestCases.APIViewTestCase):
|
|||
vm.config_template = configtemplate
|
||||
vm.save()
|
||||
|
||||
self.add_permissions('virtualization.add_virtualmachine')
|
||||
self.add_permissions('virtualization.view_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)
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
Django==5.0.5
|
||||
Django==5.0.6
|
||||
django-cors-headers==4.3.1
|
||||
django-debug-toolbar==4.3.0
|
||||
django-filter==24.2
|
||||
|
|
Loading…
Reference in New Issue