Skip to content

Commit

Permalink
[feature] Added admin classes and UI improvements for Command model o…
Browse files Browse the repository at this point in the history
…penwisp#253

- Added a writable command inlines admin to Device Admin
- Added required JS and CSS required for the UI

Related to openwisp#253

Co-authored-by: Federico Capoano<federico.capoano@gmail.com>
  • Loading branch information
pandafy committed Jun 2, 2021
1 parent 1221f98 commit a2cdd44
Show file tree
Hide file tree
Showing 26 changed files with 1,209 additions and 34 deletions.
3 changes: 2 additions & 1 deletion .jshintrc
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
"django": true,
"gettext": true,
"JSONEditor": true,
"ReconnectingWebSocket": true
"ReconnectingWebSocket": true,
"userLanguage": true
},
"browser": true
}
10 changes: 8 additions & 2 deletions openwisp_controller/config/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -469,7 +469,7 @@ def _get_preview_instance(self, request):
return c

def get_urls(self):
return [
urls = [
url(
r'^config/get-relevant-templates/(?P<organization_id>[^/]+)/$',
self.admin_site.admin_view(get_relevant_templates),
Expand All @@ -481,6 +481,12 @@ def get_urls(self):
name='get_template_default_values',
),
] + super().get_urls()
for inline in self.inlines + self.conditional_inlines:
try:
urls.extend(inline(self, self.admin_site).get_urls())
except AttributeError:
pass
return urls

def get_extra_context(self, pk=None):
ctx = super().get_extra_context(pk)
Expand All @@ -506,7 +512,7 @@ def get_inlines(self, request, obj):
inlines = list(inlines) # copy
for inline in self.conditional_inlines:
inline_instance = inline(inline.model, admin.site)
if inline_instance._get_conditional_queryset(request):
if inline_instance._get_conditional_queryset(request, obj=obj):
inlines.append(inline)
return inlines

Expand Down
2 changes: 1 addition & 1 deletion openwisp_controller/config/static/config/js/widget.js
Original file line number Diff line number Diff line change
Expand Up @@ -388,7 +388,7 @@
});
}
}
$(document).trigger('jsonschema-schemaloaded');
$(`#${el.id}`).trigger('jsonschema-schemaloaded');
});
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
{% if advanced_mode %}
<input class="button json-editor-btn-edit advanced-mode" type="button" value="{% trans 'Advanced mode (raw JSON)' %}">
{% endif %}
<script>django._jsonSchemaWidgetUrl = "{% url schema_view_name %}";</script>
{% if netjsonconfig_hint %}
<label id="netjsonconfig-hint">
Want to learn to use the advanced mode? Consult the
Expand Down
4 changes: 4 additions & 0 deletions openwisp_controller/config/tests/test_admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,10 @@ class TestAdmin(
'deviceconnection_set-INITIAL_FORMS': 0,
'deviceconnection_set-MIN_NUM_FORMS': 0,
'deviceconnection_set-MAX_NUM_FORMS': 1000,
'command_set-TOTAL_FORMS': 0,
'command_set-INITIAL_FORMS': 0,
'command_set-MIN_NUM_FORMS': 0,
'command_set-MAX_NUM_FORMS': 1000,
}
# WARNING - WATCHOUT
# this class attribute is changed dynamically
Expand Down
98 changes: 97 additions & 1 deletion openwisp_controller/connection/admin.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,22 @@
from datetime import timedelta

import swapper
from django import forms
from django.conf.urls import url
from django.contrib import admin
from django.http import JsonResponse
from django.urls import path, resolve
from django.utils.timezone import localtime
from django.utils.translation import ugettext_lazy as _

from openwisp_users.multitenancy import MultitenantOrgFilter
from openwisp_utils.admin import TimeReadonlyAdminMixin

from ..admin import MultitenantAdminMixin
from ..config.admin import DeviceAdmin
from .commands import DEFAULT_COMMANDS
from .schema import schema
from .widgets import CredentialsSchemaWidget
from .widgets import CommandSchemaWidget, CredentialsSchemaWidget

Credentials = swapper.load_model('connection', 'Credentials')
DeviceConnection = swapper.load_model('connection', 'DeviceConnection')
Expand All @@ -23,6 +29,12 @@ class Meta:
widgets = {'params': CredentialsSchemaWidget}


class CommandForm(forms.ModelForm):
class Meta:
exclude = []
widgets = {'input': CommandSchemaWidget}


@admin.register(Credentials)
class CredentialsAdmin(MultitenantAdminMixin, TimeReadonlyAdminMixin, admin.ModelAdmin):
list_display = (
Expand Down Expand Up @@ -76,3 +88,87 @@ def get_queryset(self, request):
Override MultitenantAdminMixin.get_queryset() because it breaks
"""
return super(admin.StackedInline, self).get_queryset(request)


class CommandInline(admin.StackedInline):
model = Command
verbose_name = _('Recent Commands')
verbose_name_plural = verbose_name
fields = ['status', 'type', 'input_data', 'output', 'created', 'modified']
readonly_fields = ['input_data']
# hack for openwisp-monitoring integration
# TODO: remove when this issue solved:
# https://github.com/theatlantic/django-nested-admin/issues/128#issuecomment-665833142
sortable_options = {'disabled': True}

def get_queryset(self, request, select_related=True):
"""
Return recent commands for this device
(created within the last 7 days)
"""
qs = super().get_queryset(request)
resolved = resolve(request.path_info)
if 'object_id' in resolved.kwargs:
seven_days = localtime() - timedelta(days=7)
qs = qs.filter(
device_id=resolved.kwargs['object_id'], created__gte=seven_days
).order_by('-created')
if select_related:
qs = qs.select_related()
return qs

def input_data(self, obj):
return obj.input_data

input_data.short_description = _('input')

def _get_conditional_queryset(self, request, obj, select_related=False):
return self.get_queryset(request, select_related=select_related).exists()

def has_delete_permission(self, request, obj):
return False

def has_add_permission(self, request, obj):
return False

def has_change_permission(self, request, obj):
return False


class CommandWritableInline(admin.StackedInline):
model = Command
extra = 1
form = CommandForm
fields = ['type', 'input']

def get_queryset(self, request, select_related=True):
return self.model.objects.none()

def _get_conditional_queryset(self, request, obj, select_related=False):
return bool(obj)

def get_urls(self):
options = self.model._meta
url_prefix = f'{options.app_label}_{options.model_name}'
return [
path(
f'{options.app_label}/{options.model_name}/ui/schema.json',
self.admin_site.admin_view(self.schema_view),
name=f'{url_prefix}_schema',
),
]

def schema_view(self, request):
result = {}
for key, value in DEFAULT_COMMANDS.items():
result.update({key: value['schema']})
return JsonResponse(result)


DeviceAdmin.inlines += [DeviceConnectionInline]
DeviceAdmin.conditional_inlines += [
CommandWritableInline,
# this inline must come after CommandWritableInline
# or the JS logic will not work
CommandInline,
]
1 change: 1 addition & 0 deletions openwisp_controller/connection/api/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class CommandSerializer(serializers.ModelSerializer):
def to_representation(self, instance):
repr = super().to_representation(instance)
repr['type'] = instance.get_type_display()
repr['input'] = instance.input_data
return repr

class Meta:
Expand Down
23 changes: 21 additions & 2 deletions openwisp_controller/connection/api/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from django.core.exceptions import ValidationError
from rest_framework.authentication import SessionAuthentication
from rest_framework.exceptions import NotFound
from rest_framework.generics import (
GenericAPIView,
ListCreateAPIView,
Expand All @@ -13,6 +15,7 @@
from .serializer import CommandSerializer

Command = load_model('connection', 'Command')
Device = load_model('config', 'Device')


class CommandPaginator(PageNumberPagination):
Expand All @@ -29,9 +32,25 @@ class BaseCommandView(GenericAPIView):
def get_queryset(self):
qs = Command.objects.prefetch_related('device')
if not self.request.user.is_superuser:
qs.filter(device__organization__in=self.request.user.organizations_managed)
qs = qs.filter(
device__organization__in=self.request.user.organizations_managed
)
return qs

def initial(self, *args, **kwargs):
super().initial(*args, **kwargs)
self.assert_parent_exists()

def assert_parent_exists(self):
try:
assert self.get_parent_queryset().exists()
except (AssertionError, ValidationError):
device_pk = self.kwargs['device_pk']
raise NotFound(detail=f'Device with ID "{device_pk}" not found.')

def get_parent_queryset(self):
return Device.objects.filter(pk=self.kwargs['device_pk'])


class CommandListCreateView(BaseCommandView, ListCreateAPIView):
pagination_class = CommandPaginator
Expand All @@ -40,7 +59,7 @@ def get_queryset(self):
return super().get_queryset().filter(device_id=self.kwargs['device_pk'])

def perform_create(self, serializer):
serializer.save(device_id=self.kwargs['device_pk'],)
serializer.save(device_id=self.kwargs['device_pk'])


class CommandDetailsView(BaseCommandView, RetrieveAPIView):
Expand Down
4 changes: 3 additions & 1 deletion openwisp_controller/connection/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,9 @@ def ready(self):
)

post_save.connect(
self.command_save_receiver, sender=Command, dispatch_uid="command_save_handler"
self.command_save_receiver,
sender=Command,
dispatch_uid="command_save_handler",
)

@classmethod
Expand Down
8 changes: 8 additions & 0 deletions openwisp_controller/connection/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,7 @@ def _add_output(self, output):
"""
adds trailing new line if output doesn't have it
"""
output = str(output) # convert __proxy__ strings
if not output.endswith('\n'):
output += '\n'
self.output += output
Expand Down Expand Up @@ -475,6 +476,13 @@ def arguments(self):
return self.input.values()
return []

@property
def input_data(self):
if self.is_custom:
return self.custom_command
else:
return ', '.join(self.arguments)

@property
def _schema(self):
return get_command_schema(self.type)
Expand Down
2 changes: 1 addition & 1 deletion openwisp_controller/connection/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@
'definitions': {
'password_regex': {
'type': 'string',
'minLength': 4,
'minLength': 6,
'maxLength': 30,
'pattern': '[\S]',
}
Expand Down
Loading

0 comments on commit a2cdd44

Please sign in to comment.