Skip to content

Commit fd83497

Browse files
committed
[feature] Proof of concept need some code refinement
1 parent cbe8ea6 commit fd83497

6 files changed

Lines changed: 293 additions & 24 deletions

File tree

openwisp_controller/connection/api/serializers.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
DeviceConnection = load_model("connection", "DeviceConnection")
1313
Credentials = load_model("connection", "Credentials")
1414
Device = load_model("config", "Device")
15+
BatchCommand = load_model("connection", "BatchCommand")
1516

1617

1718
class ValidatedDeviceFieldSerializer(ValidatedModelSerializer):
@@ -43,6 +44,10 @@ class CommandSerializer(ValidatedDeviceFieldSerializer):
4344
required=False,
4445
pk_field=serializers.UUIDField(format="hex_verbose"),
4546
)
47+
batch_command = serializers.PrimaryKeyRelatedField(
48+
read_only=True,
49+
pk_field=serializers.UUIDField(format="hex_verbose"),
50+
)
4651

4752
def __init__(self, *args, **kwargs):
4853
super().__init__(*args, **kwargs)
@@ -115,3 +120,54 @@ class Meta:
115120
"is_working": {"read_only": True},
116121
}
117122
read_only_fields = ("created", "modified")
123+
124+
125+
class BatchCommandExecuteSerializer(
126+
FilterSerializerByOrgManaged, serializers.ModelSerializer
127+
):
128+
type = serializers.CharField(source="command_type")
129+
input = serializers.JSONField(
130+
source="command_input", allow_null=True, required=False
131+
)
132+
devices = serializers.PrimaryKeyRelatedField(
133+
many=True,
134+
queryset=Device.objects.all(),
135+
required=False,
136+
allow_empty=True,
137+
pk_field=serializers.UUIDField(format="hex_verbose"),
138+
)
139+
140+
class Meta:
141+
model = BatchCommand
142+
fields = (
143+
"organization",
144+
"type",
145+
"input",
146+
"devices",
147+
"group",
148+
"location",
149+
)
150+
extra_kwargs = {
151+
"organization": {"required": False, "allow_null": True},
152+
}
153+
154+
def validate(self, data):
155+
if (
156+
not data.get("organization")
157+
and not self.context["request"].user.is_superuser
158+
):
159+
raise serializers.ValidationError(
160+
_("Only superusers can execute batch commands without an organization.")
161+
)
162+
if devices := data.get("devices"):
163+
org = data.get("organization")
164+
for device in devices:
165+
if org and device.organization_id != org.id:
166+
raise serializers.ValidationError(
167+
{
168+
"devices": _(
169+
"All devices must belong to the same organization."
170+
)
171+
}
172+
)
173+
return data

openwisp_controller/connection/api/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,11 @@ def get_api_urls(api_views):
4040
api_views.deviceconnection_detail_view,
4141
name="deviceconnection_detail",
4242
),
43+
path(
44+
"api/v1/controller/batch-command/execute/",
45+
api_views.batch_command_execute_view,
46+
name="batch_command_execute",
47+
),
4348
]
4449

4550

openwisp_controller/connection/api/views.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
from django.utils.translation import gettext_lazy as _
22
from drf_yasg import openapi
33
from drf_yasg.utils import swagger_auto_schema
4+
from rest_framework import status
45
from rest_framework.generics import (
6+
GenericAPIView,
57
ListCreateAPIView,
68
RetrieveAPIView,
79
RetrieveUpdateDestroyAPIView,
810
get_object_or_404,
911
)
12+
from rest_framework.response import Response
1013
from swapper import load_model
1114

1215
from openwisp_utils.api.pagination import OpenWispPagination
@@ -17,6 +20,7 @@
1720
RelatedDeviceProtectedAPIMixin,
1821
)
1922
from .serializers import (
23+
BatchCommandExecuteSerializer,
2024
CommandSerializer,
2125
CredentialSerializer,
2226
DeviceConnectionSerializer,
@@ -26,6 +30,7 @@
2630
Device = load_model("config", "Device")
2731
Credentials = load_model("connection", "Credentials")
2832
DeviceConnection = load_model("connection", "DeviceConnection")
33+
BatchCommand = load_model("connection", "BatchCommand")
2934

3035

3136
class BaseCommandView(RelatedDeviceProtectedAPIMixin):
@@ -138,6 +143,33 @@ class DeviceConnectionListCreateView(BaseDeviceConnection, ListCreateAPIView):
138143
DeviceConnenctionListCreateView = DeviceConnectionListCreateView
139144

140145

146+
class BatchCommandExecuteView(ProtectedAPIMixin, GenericAPIView):
147+
model = BatchCommand
148+
queryset = BatchCommand.objects.all()
149+
serializer_class = BatchCommandExecuteSerializer
150+
151+
def post(self, request):
152+
serializer = self.get_serializer(data=request.data)
153+
serializer.is_valid(raise_exception=True)
154+
batch = serializer.save()
155+
batch.launch_async()
156+
return Response({"batch": str(batch.pk)}, status=status.HTTP_201_CREATED)
157+
158+
def get(self, request):
159+
serializer = self.get_serializer(data=request.query_params)
160+
serializer.is_valid(raise_exception=True)
161+
data = serializer.validated_data
162+
device_pks = []
163+
devices_list = data.pop("devices", None)
164+
if devices_list:
165+
device_pks = [str(d.pk) for d in devices_list]
166+
batch = BatchCommand(**data)
167+
if not device_pks:
168+
resolved = batch.resolve_devices()
169+
device_pks = [str(d.pk) for d in resolved]
170+
return Response({"devices": device_pks})
171+
172+
141173
class DeviceConnectionDetailView(BaseDeviceConnection, RetrieveUpdateDestroyAPIView):
142174
def get_object(self):
143175
queryset = self.filter_queryset(self.get_queryset())
@@ -158,3 +190,5 @@ def get_object(self):
158190

159191
# TODO: remove in version 1.4
160192
deviceconnection_details_view = deviceconnection_detail_view
193+
194+
batch_command_execute_view = BatchCommandExecuteView.as_view()

openwisp_controller/connection/base/models.py

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,8 @@ def save(self, *args, **kwargs):
563563
output = super().save(*args, **kwargs)
564564
if adding:
565565
self._schedule_command()
566+
if self.batch_command_id and self.status != "in-progress":
567+
self.batch_command.calculate_and_update_status()
566568
return output
567569

568570
def _save_without_resurrecting(self):
@@ -738,6 +740,8 @@ class AbstractBatchCommand(TimeStampedEditableModel):
738740
organization = models.ForeignKey(
739741
get_model_name("openwisp_users", "Organization"),
740742
on_delete=models.CASCADE,
743+
blank=True,
744+
null=True,
741745
)
742746
status = models.CharField(
743747
max_length=12, choices=STATUS_CHOICES, default=STATUS_CHOICES[0][0]
@@ -761,37 +765,55 @@ class AbstractBatchCommand(TimeStampedEditableModel):
761765
null=True,
762766
verbose_name=_("location"),
763767
)
764-
include_all_devices = models.BooleanField(default=False)
768+
devices = models.ManyToManyField(
769+
get_model_name("config", "Device"),
770+
blank=True,
771+
verbose_name=_("devices"),
772+
)
765773
total_devices = models.PositiveIntegerField(default=0)
766774
successful = models.PositiveIntegerField(default=0)
767775
failed = models.PositiveIntegerField(default=0)
768776
cancelled = models.PositiveIntegerField(default=0)
769777

770778
class Meta:
771779
abstract = True
772-
verbose_name = _("Batch command operation")
773-
verbose_name_plural = _("Batch command operations")
780+
verbose_name = _("Batch command")
781+
verbose_name_plural = _("Batch commands")
774782

775783
def clean(self):
776784
super().clean()
777-
if self.group and self.group.organization != self.organization:
778-
raise ValidationError(
779-
{
780-
"group": _(
781-
"The organization of the group doesn't match "
782-
"the organization of the batch command operation"
783-
)
784-
}
785-
)
786-
if self.location and self.location.organization != self.organization:
787-
raise ValidationError(
788-
{
789-
"location": _(
790-
"The organization of the location doesn't match "
791-
"the organization of the batch command operation"
785+
if self.organization_id:
786+
if self.group and self.group.organization != self.organization:
787+
raise ValidationError(
788+
{
789+
"group": _(
790+
"The organization of the group doesn't match "
791+
"the organization of the batch command operation"
792+
)
793+
}
794+
)
795+
if self.location and self.location.organization != self.organization:
796+
raise ValidationError(
797+
{
798+
"location": _(
799+
"The organization of the location doesn't match "
800+
"the organization of the batch command operation"
801+
)
802+
}
803+
)
804+
if self.pk and self.devices.exists():
805+
org_mismatch = self.devices.exclude(
806+
organization=self.organization
807+
).exists()
808+
if org_mismatch:
809+
raise ValidationError(
810+
{
811+
"devices": _(
812+
"All devices must belong to the same "
813+
"organization as the batch command."
814+
)
815+
}
792816
)
793-
}
794-
)
795817
allowed = dict(
796818
AbstractCommand.get_org_allowed_commands(
797819
organization_id=self.organization_id
@@ -813,14 +835,16 @@ def clean(self):
813835
raise ValidationError({"command_input": e.message})
814836

815837
def resolve_devices(self):
838+
if self.pk and self.devices.exists():
839+
return self.devices.all()
816840
Device = load_model("config", "Device")
817-
qs = Device.objects.filter(organization=self.organization)
841+
qs = Device.objects.all()
842+
if self.organization_id:
843+
qs = qs.filter(organization=self.organization)
818844
if self.group:
819845
qs = qs.filter(group=self.group)
820846
if self.location:
821847
qs = qs.filter(location=self.location)
822-
if not self.include_all_devices and not self.group and not self.location:
823-
qs = qs.none()
824848
return qs
825849

826850
def launch(self):

0 commit comments

Comments
 (0)