Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 72 additions & 3 deletions openwisp_firmware_upgrader/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,30 @@ class Meta(BaseMeta):
class UpgradeOperationSerializer(serializers.ModelSerializer):
class Meta:
model = UpgradeOperation
fields = ("id", "device", "image", "status", "log", "modified", "created")
fields = (
"id",
"device",
"image",
"status",
"log",
"modified",
"created",
"upgrade_options",
)


class DeviceUpgradeOperationSerializer(serializers.ModelSerializer):
class Meta:
model = UpgradeOperation
fields = ("id", "device", "image", "status", "log", "modified")
fields = (
"id",
"device",
"image",
"status",
"log",
"modified",
"upgrade_options",
)


class BatchUpgradeOperationListSerializer(BaseSerializer):
Expand All @@ -116,9 +133,11 @@ class Meta:


class DeviceFirmwareSerializer(ValidatedModelSerializer):
upgrade_options = serializers.JSONField(required=False, allow_null=True)

class Meta:
model = DeviceFirmware
fields = ("id", "image", "installed", "modified")
fields = ("id", "image", "installed", "modified", "upgrade_options")
read_only_fields = ("installed", "modified")

def validate(self, data):
Expand All @@ -142,8 +161,58 @@ def validate(self, data):
)
}
)
# Validate upgrade_options if provided
upgrade_options = data.get("upgrade_options")
if upgrade_options is not None:
# Create a temporary UpgradeOperation to validate upgrade_options
# This will trigger the model's validation logic
temp_operation = UpgradeOperation(
device=device,
image=image,
upgrade_options=upgrade_options or {},
)
try:
temp_operation.validate_upgrade_options()
except ValidationError as e:
raise serializers.ValidationError({"upgrade_options": e.messages})
return super().validate(data)

def to_representation(self, instance):
"""
Include upgrade_options from the latest upgrade operation if available.
"""
ret = super().to_representation(instance)
# Get upgrade_options from the latest upgrade operation for this device
try:
latest_operation = instance.device.upgradeoperation_set.latest("created")
ret["upgrade_options"] = latest_operation.upgrade_options
except UpgradeOperation.DoesNotExist:
ret["upgrade_options"] = {}
return ret

def create(self, validated_data):
"""
Extract upgrade_options from validated_data and pass it to model.save()
"""
upgrade_options = validated_data.pop("upgrade_options", None)
if upgrade_options is None:
upgrade_options = {}
instance = DeviceFirmware(**validated_data)
instance.save(upgrade_options=upgrade_options)
return instance

def update(self, instance, validated_data):
"""
Extract upgrade_options from validated_data and pass it to model.save()
"""
upgrade_options = validated_data.pop("upgrade_options", None)
if upgrade_options is None:
upgrade_options = {}
for attr, value in validated_data.items():
setattr(instance, attr, value)
instance.save(upgrade_options=upgrade_options)
return instance

def _get_device_object(self, device_id):
try:
device = Device.objects.get(id=device_id)
Expand Down
33 changes: 31 additions & 2 deletions openwisp_firmware_upgrader/api/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import json

import swapper
from django.core.exceptions import ValidationError
from django.http import Http404
Expand Down Expand Up @@ -86,9 +88,36 @@ def post(self, request, pk):
"""
Upgrades all the devices related to the specified build ID.
"""
upgrade_all = request.POST.get("upgrade_all") is not None
upgrade_all = (
request.data.get("upgrade_all") is not None
or request.POST.get("upgrade_all") is not None
)
upgrade_options = request.data.get("upgrade_options")
# If not in request.data, try request.POST (for form data)
if upgrade_options is None:
upgrade_options = request.POST.get("upgrade_options")
# Parse upgrade_options if it's a string (from form data)
if isinstance(upgrade_options, str):
try:
upgrade_options = json.loads(upgrade_options)
except (json.JSONDecodeError, ValueError):
upgrade_options = {}
if upgrade_options is None:
upgrade_options = {}
instance = self.get_object()
batch = instance.batch_upgrade(firmwareless=upgrade_all)
# Validate upgrade_options by creating a temporary BatchUpgradeOperation
temp_batch = BatchUpgradeOperation(
build=instance, upgrade_options=upgrade_options
)
try:
temp_batch.full_clean()
except ValidationError as e:
return Response(
{"upgrade_options": e.messages}, status=status.HTTP_400_BAD_REQUEST
)
batch = instance.batch_upgrade(
firmwareless=upgrade_all, upgrade_options=upgrade_options
)
return Response({"batch": str(batch.pk)}, status=201)

def get(self, request, pk):
Expand Down
7 changes: 5 additions & 2 deletions openwisp_firmware_upgrader/base/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,12 +55,15 @@ class Meta:
def validate_upgrade_options(self):
if not self.upgrade_options:
return
if not getattr(self.upgrader_class, "SCHEMA"):
upgrader_class = self.upgrader_class
if not upgrader_class:
return
if not getattr(upgrader_class, "SCHEMA", None):
raise ValidationError(
_("Using upgrade options is not allowed with this upgrader.")
)
try:
self.upgrader_class.validate_upgrade_options(self.upgrade_options)
upgrader_class.validate_upgrade_options(self.upgrade_options)
except jsonschema.ValidationError:
raise ValidationError("The upgrade options are invalid")
except FirmwareUpgradeOptionsException as error:
Expand Down
127 changes: 116 additions & 11 deletions openwisp_firmware_upgrader/tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ def test_api_batch_upgrade(self):
self.assertEqual(DeviceFirmware.objects.count(), 0)
with self.subTest("Existing build"):
url = reverse("upgrader:api_build_batch_upgrade", args=[build.pk])
with self.assertNumQueries(8):
with self.assertNumQueries(10):
r = self.client.post(url)
self.assertEqual(BatchUpgradeOperation.objects.count(), 1)
self.assertEqual(DeviceFirmware.objects.count(), 0)
Expand Down Expand Up @@ -403,7 +403,7 @@ def test_api_shared_build_batch_upgrade(self):
)

with self.subTest("Test superuser can mass upgrade shared build"):
with self.assertNumQueries(5):
with self.assertNumQueries(7):
response = self.client.post(path)
self.assertEqual(response.status_code, 201)
batch = BatchUpgradeOperation.objects.first()
Expand All @@ -413,7 +413,35 @@ def test_build_upgradeable_404(self):
url = reverse("upgrader:api_build_batch_upgrade", args=[uuid.uuid4()])
with self.assertNumQueries(4):
r = self.client.get(url)
self.assertEqual(r.status_code, 404)
self.assertEqual(r.status_code, 404)

def test_api_batch_upgrade_with_upgrade_options(self):
"""Test batch upgrade accepts upgrade_options"""
build = self._create_build()
url = reverse("upgrader:api_build_batch_upgrade", args=[build.pk])
upgrade_options = {"c": True, "F": True}
r = self.client.post(
url, {"upgrade_options": upgrade_options}, content_type="application/json"
)
self.assertEqual(r.status_code, 201)
batch = BatchUpgradeOperation.objects.first()
self.assertEqual(batch.upgrade_options, upgrade_options)

def test_api_batch_upgrade_validates_upgrade_options(self):
"""Test batch upgrade validates upgrade_options"""
# Create a build with a device so upgrader_class can be determined
env = self._create_upgrade_env()
build = env["build2"]
url = reverse("upgrader:api_build_batch_upgrade", args=[build.pk])
# -n and -c are mutually exclusive
invalid_upgrade_options = {"c": True, "n": True}
r = self.client.post(
url,
{"upgrade_options": invalid_upgrade_options},
content_type="application/json",
)
self.assertEqual(r.status_code, 400)
self.assertIn("upgrade_options", r.data)
self.assertEqual(BatchUpgradeOperation.objects.count(), 0)


Expand Down Expand Up @@ -794,6 +822,18 @@ def test_batchupgradeoperation_view(self):
r = self.client.get(url)
self.assertEqual(r.data, serialized)

def test_batchupgradeoperation_includes_upgrade_options(self):
"""Test that batch upgrade operation includes upgrade_options in response"""
build = self._create_build()
batch = BatchUpgradeOperation.objects.create(
build=build, upgrade_options={"c": True, "F": True}
)
url = reverse("upgrader:api_batchupgradeoperation_detail", args=[batch.pk])
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertIn("upgrade_options", r.data)
self.assertEqual(r.data["upgrade_options"], {"c": True, "F": True})


class TestFirmwareImageViews(TestAPIUpgraderMixin, TestCase):
def _serialize_image(self, firmware):
Expand Down Expand Up @@ -1159,7 +1199,7 @@ def test_device_firmware_detail_get(self):
url = reverse(
"upgrader:api_devicefirmware_detail", args=[device_fw1.device.pk]
)
with self.assertNumQueries(9):
with self.assertNumQueries(13):
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot Jan 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find the test file and examine the specific test methods
fd test_api.py | head -1 | xargs wc -l

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 128


🏁 Script executed:

#!/bin/bash
# Get the specific lines mentioned in the review to understand what changed
fd test_api.py | head -1 | xargs sed -n '1195,1210p;1245,1255p;1283,1295p;1313,1325p;1352,1365p'

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 3298


🏁 Script executed:

#!/bin/bash
# Search for DeviceFirmwareSerializer and its related fields
rg -n 'class DeviceFirmwareSerializer' openwisp_firmware_upgrader/api/

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 186


🏁 Script executed:

#!/bin/bash
# Check for select_related/prefetch_related usage in views and serializers
rg -B 3 -A 3 'select_related|prefetch_related' openwisp_firmware_upgrader/api/views.py openwisp_firmware_upgrader/api/serializers.py | head -100

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 5170


🏁 Script executed:

#!/bin/bash
# Search for upgrade_options in the codebase to understand the new feature
rg -n 'upgrade_options' openwisp_firmware_upgrader/

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 20393


🏁 Script executed:

#!/bin/bash
# Get the full DeviceFirmwareSerializer implementation
sed -n '135,220p' openwisp_firmware_upgrader/api/serializers.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 3446


🏁 Script executed:

#!/bin/bash
# Check if there's prefetch_related in DeviceFirmwareDetailView queryset
grep -n "class DeviceFirmwareDetailView" openwisp_firmware_upgrader/api/views.py -A 10

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 504


🏁 Script executed:

#!/bin/bash
# Look for any prefetch setup for UpgradeOperation
rg -n "prefetch_related" openwisp_firmware_upgrader/api/ -B 2 -A 2

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 505


🏁 Script executed:

#!/bin/bash
# Check the test methods to understand what operations trigger queries
sed -n '1190,1210p' openwisp_firmware_upgrader/tests/test_api.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1134


🏁 Script executed:

#!/bin/bash
# Check if there's any prefetch_related for upgradeoperation_set in the view
rg -n "prefetch_related.*upgradeoperation" openwisp_firmware_upgrader/api/

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 172


🏁 Script executed:

#!/bin/bash
# Check the earlier version to see what the original query count was
git log --oneline openwisp_firmware_upgrader/tests/test_api.py | head -20

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 109


🏁 Script executed:

#!/bin/bash
# Look at the git diff to see what changed in the query counts
git diff HEAD~1 openwisp_firmware_upgrader/tests/test_api.py | grep -A 3 -B 3 "assertNumQueries" | head -50

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 265


🏁 Script executed:

#!/bin/bash
# Get the full DeviceFirmwareDetailView implementation
sed -n '281,305p' openwisp_firmware_upgrader/api/views.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1034


🏁 Script executed:

#!/bin/bash
# Check the model to see if there are any query optimization hints
rg -n "class DeviceFirmware" openwisp_firmware_upgrader/base/models.py -A 20 | head -40

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Look for the actual git commit that added upgrade_options to see what changed
git log --all --oneline | grep -i "upgrade" | head -10

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 851


🏁 Script executed:

#!/bin/bash
# Check the commit that added upgrade_options feature
git show 86a6605 --stat | head -30

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1641


🏁 Script executed:

#!/bin/bash
# Look at what changed specifically in serializers and views for upgrade_options
git show 86a6605 -- openwisp_firmware_upgrader/api/serializers.py openwisp_firmware_upgrader/api/views.py | grep -A 5 -B 5 "to_representation\|upgrade_options" | head -80

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Check the N+1 issue: instance.device.upgradeoperation_set in to_representation
# This is called for EVERY DeviceFirmware instance, but the view doesn't prefetch it
# Let's verify the view doesn't have prefetch_related for upgradeoperation_set
grep -A 5 "class DeviceFirmwareDetailView" openwisp_firmware_upgrader/api/views.py | grep -E "queryset|prefetch"

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 150


🏁 Script executed:

#!/bin/bash
# Count how many upgrade operations would be queried per test
# The to_representation() method calls:
# instance.device.upgradeoperation_set.latest("created")
# This is an unoptimized query that happens for EVERY serialized DeviceFirmware

# Let's verify by looking at the test to see if this pattern exists
rg -n "to_representation" openwisp_firmware_upgrader/api/serializers.py -B 2 -A 15

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1768


🏁 Script executed:

#!/bin/bash
# Search for any Prefetch imports or usage in serializers
rg -n "Prefetch|prefetch_related" openwisp_firmware_upgrader/api/serializers.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Check if there's a comment or TODO about optimizing the upgrade_options query
rg -n "N\+1|optimize|prefetch" openwisp_firmware_upgrader/api/ -i

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 172


🏁 Script executed:

#!/bin/bash
# Verify the exact behavior: does the view need to be modified to avoid N+1?
# Let's check if there are any recent changes to optimize this
git log --all --oneline -- openwisp_firmware_upgrader/api/views.py | head -5

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 350


🏁 Script executed:

#!/bin/bash
# Check the exact test that's failing to understand what queries are being run
sed -n '1195,1220p' openwisp_firmware_upgrader/tests/test_api.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1483


🏁 Script executed:

#!/bin/bash
# Check if DeviceFirmwareListView exists and how it's optimized
rg -n "class DeviceFirmwareListView" openwisp_firmware_upgrader/api/views.py -A 10

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Look for any list endpoints that use DeviceFirmware
rg -n "DeviceFirmware" openwisp_firmware_upgrader/api/views.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 543


🏁 Script executed:

#!/bin/bash
# Check the to_representation method more carefully - is it using select_related properly?
# The issue is: instance.device.upgradeoperation_set.latest("created")
# This requires the device to be prefetched AND the upgrade operations to be prefetched
# Let's see if this pattern appears elsewhere
rg -n "upgradeoperation_set" openwisp_firmware_upgrader/

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 3511


🏁 Script executed:

#!/bin/bash
# Check if the view can be modified to add prefetch_related
# Look at how get_queryset is implemented
sed -n '281,320p' openwisp_firmware_upgrader/api/views.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1551


🏁 Script executed:

#!/bin/bash
# Check lines 341 and 348 in views.py to see the other place where upgradeoperation_set is accessed
sed -n '335,355p' openwisp_firmware_upgrader/api/views.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 1073


🏁 Script executed:

#!/bin/bash
# Look at the BatchUpgradeOperationDetailView to understand the proper prefetch pattern
sed -n '160,175p' openwisp_firmware_upgrader/api/views.py

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 591


🏁 Script executed:

#!/bin/bash
# Verify if there's a Prefetch import and how it's used elsewhere
rg -n "from django.db.models import.*Prefetch" openwisp_firmware_upgrader/

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 61


🏁 Script executed:

#!/bin/bash
# Check if there are any comments in the PR about the N+1 issue or query optimization
git log --all --grep="upgrade_options\|N+1" --oneline | head -10

Repository: openwisp/openwisp-firmware-upgrader

Length of output: 61


Fix N+1 query issue in DeviceFirmwareDetailView.

The query count increases (13, 29, 28, 30, 31) are caused by an unoptimized N+1 pattern in DeviceFirmwareSerializer.to_representation() (line 187), which calls instance.device.upgradeoperation_set.latest("created") without prefetching. This same pattern is repeated in the view's put and patch handlers (lines 341, 348).

Add prefetch_related("device__upgradeoperation_set") to DeviceFirmwareDetailView.queryset to batch the queries, similar to the pattern used in BatchUpgradeOperationDetailView (line 169).

🤖 Prompt for AI Agents
In openwisp_firmware_upgrader/tests/test_api.py around line 1202, the test
exposes an N+1 query problem originating from
DeviceFirmwareSerializer.to_representation (line 187) and repeated in
DeviceFirmwareDetailView.put/patch handlers (lines 341, 348); fix it by updating
DeviceFirmwareDetailView.queryset to include
prefetch_related("device__upgradeoperation_set") (following the
BatchUpgradeOperationDetailView pattern at line 169) so latest upgrade
operations for each device are prefetched and the repeated
instance.device.upgradeoperation_set.latest("created") calls no longer trigger
extra queries.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@youhaveme9 this seems a good catch

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!

r = self.client.get(url, {"format": "api"})
self.assertEqual(r.status_code, 200)
serializer_detail = self._serialize_device_firmware(device_fw1)
Expand Down Expand Up @@ -1207,7 +1247,7 @@ def test_device_firmware_detail_create(self):
self.assertEqual(DeviceFirmware.objects.count(), 0)
self.assertEqual(UpgradeOperation.objects.count(), 0)

with self.assertNumQueries(26):
with self.assertNumQueries(29):
data = {"image": image1a.pk}
# This API view allows the creation
# of new devicefirmware objects with
Expand Down Expand Up @@ -1245,7 +1285,7 @@ def test_device_firmware_detail_create_shared_image(self):
self.assertEqual(UpgradeOperation.objects.count(), 0)

self.client.force_login(self.administrator)
with self.assertNumQueries(25):
with self.assertNumQueries(28):
data = {"image": shared_image.pk}
r = self.client.put(
f"{path}?format=api", data, content_type="application/json"
Expand Down Expand Up @@ -1275,7 +1315,7 @@ def test_device_firmware_detail_update(self):
self.assertEqual(DeviceFirmware.objects.count(), 2)
self.assertEqual(UpgradeOperation.objects.count(), 0)

with self.assertNumQueries(27):
with self.assertNumQueries(30):
data = {"image": image2a.pk}
r = self.client.put(
f"{url}?format=api", data, content_type="application/json"
Expand Down Expand Up @@ -1314,7 +1354,7 @@ def test_device_firmware_detail_partial_update(self):
self.assertEqual(DeviceFirmware.objects.count(), 2)
self.assertEqual(UpgradeOperation.objects.count(), 0)

with self.assertNumQueries(27):
with self.assertNumQueries(31):
data = {"image": image2a.pk}
r = self.client.patch(
f"{url}?format=api", data, content_type="application/json"
Expand All @@ -1336,6 +1376,41 @@ def test_device_firmware_detail_partial_update(self):
self.assertNotIn(f"{image2b}</option>", repsonse)
self.assertNotIn(f"{image2}</option>", repsonse)

def test_device_firmware_detail_with_upgrade_options(self):
"""Test device firmware update accepts upgrade_options"""
env = self._create_upgrade_env()
device_fw = env["device_fw1"]
new_image = env["image2a"]
url = reverse("upgrader:api_devicefirmware_detail", args=[device_fw.device.pk])
upgrade_options = {"c": True, "F": True}
r = self.client.put(
url,
{"image": new_image.pk, "upgrade_options": upgrade_options},
content_type="application/json",
)
self.assertEqual(r.status_code, 200)
upgrade_operation = UpgradeOperation.objects.first()
self.assertEqual(upgrade_operation.upgrade_options, upgrade_options)
self.assertIn("upgrade_options", r.data)
self.assertEqual(r.data["upgrade_options"], upgrade_options)

def test_device_firmware_detail_validates_upgrade_options(self):
"""Test device firmware update validates upgrade_options"""
env = self._create_upgrade_env()
device_fw = env["device_fw1"]
new_image = env["image2a"]
url = reverse("upgrader:api_devicefirmware_detail", args=[device_fw.device.pk])
# -n and -c are mutually exclusive
invalid_upgrade_options = {"c": True, "n": True}
r = self.client.put(
url,
{"image": new_image.pk, "upgrade_options": invalid_upgrade_options},
content_type="application/json",
)
self.assertEqual(r.status_code, 400)
self.assertIn("upgrade_options", r.data)
self.assertEqual(UpgradeOperation.objects.count(), 0)

def test_device_firmware_detail_multitenancy(self):
(
d1,
Expand All @@ -1349,7 +1424,7 @@ def test_device_firmware_detail_multitenancy(self):
with self.subTest("Test device firmware detail org manager"):
self._login("org1_manager", "tester")
url = reverse("upgrader:api_devicefirmware_detail", args=[d1.pk])
with self.assertNumQueries(7):
with self.assertNumQueries(8):
r = self.client.get(url, {"format": "api"})
self.assertEqual(r.status_code, 200)
serializer_detail = self._serialize_device_firmware(device_fw1)
Expand Down Expand Up @@ -1382,15 +1457,15 @@ def test_device_firmware_detail_multitenancy(self):
with self.subTest("Test device firmware detail org admin"):
self._login("org_admin", "tester")
url = reverse("upgrader:api_devicefirmware_detail", args=[d1.pk])
with self.assertNumQueries(6):
with self.assertNumQueries(10):
r = self.client.get(url, {"format": "api"})
self.assertEqual(r.status_code, 200)
serializer_detail = self._serialize_device_firmware(device_fw1)
self.assertEqual(r.data, serializer_detail)
self.assertContains(r, f"{image1}</option>")
self.assertNotContains(r, f"{image2}</option>")
url = reverse("upgrader:api_devicefirmware_detail", args=[d2.pk])
with self.assertNumQueries(6):
with self.assertNumQueries(10):
r = self.client.get(url, {"format": "api"})
self.assertEqual(r.status_code, 200)
serializer_detail = self._serialize_device_firmware(device_fw2)
Expand Down Expand Up @@ -1557,6 +1632,22 @@ def test_device_uo_list_multitenancy(self):
serializer_list = self._serialize_device_upgrade_operation(device_uo2)
self.assertEqual(r.data["results"], [serializer_list])

def test_device_upgrade_operation_includes_upgrade_options(self):
"""Test that device upgrade operation includes upgrade_options in response"""
device_fw = self._create_device_firmware(upgrade=True)
upgrade_operation = UpgradeOperation.objects.first()
upgrade_operation.upgrade_options = {"c": True, "F": True}
upgrade_operation.save()
url = reverse(
"upgrader:api_deviceupgradeoperation_list", args=[device_fw.device.pk]
)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertGreater(len(r.data["results"]), 0)
result = r.data["results"][0]
self.assertIn("upgrade_options", result)
self.assertEqual(result["upgrade_options"], {"c": True, "F": True})


class TestUpgradeOperationViews(TestAPIUpgraderMixin, TestCase):
def _serialize_upgrade_operation(self, uo, many=False):
Expand Down Expand Up @@ -1787,6 +1878,20 @@ def test_uo_list_detail_multitenancy(self):
serializer_list = self._serialize_upgrade_operation(uo_qs, many=True)
self.assertEqual(r.data["results"], serializer_list)

def test_upgrade_operation_includes_upgrade_options(self):
"""Test that upgrade operation includes upgrade_options in response"""
self._create_device_firmware(upgrade=True)
upgrade_operation = UpgradeOperation.objects.first()
upgrade_operation.upgrade_options = {"c": True, "F": True}
upgrade_operation.save()
url = reverse(
"upgrader:api_upgradeoperation_detail", args=[upgrade_operation.pk]
)
r = self.client.get(url)
self.assertEqual(r.status_code, 200)
self.assertIn("upgrade_options", r.data)
self.assertEqual(r.data["upgrade_options"], {"c": True, "F": True})


class TestOrgAPIMixin(TestAPIUpgraderMixin, TestCase):
def _serialize_build(self, build):
Expand Down
Loading