Repository: openwisp/openwisp-firmware-upgrader (language: python)

Issue:
[feature] Add support sysupgrade options such as -n and --force
OpenWISP firmware upgrader does not seem to support `sysupgrade -n` or `-F` upgrades.  This feature may be necessary if a _swconfig to to DSA_ conversion script cannot be implemented in OpenWrt because currently sysupgrade will fail with an image incompatibility error when upgrading.


Discussion / hints:

Fix patch:
diff --git a/.jshintrc b/.jshintrc
index 64de7d45..279ae675 100644
--- a/.jshintrc
+++ b/.jshintrc
@@ -15,7 +15,8 @@
         "owNotifyModelName": true,
         "owIsChangeForm": true,
         "getAbsoluteUrl": true,
-        "dateTimeStampToDateTimeLocaleString": true
+        "dateTimeStampToDateTimeLocaleString": true,
+        "firmwareUpgraderSchema": true
     },
     "browser": true
 }
diff --git a/openwisp_firmware_upgrader/admin.py b/openwisp_firmware_upgrader/admin.py
index d51173e7..db340599 100644
--- a/openwisp_firmware_upgrader/admin.py
+++ b/openwisp_firmware_upgrader/admin.py
@@ -1,3 +1,4 @@
+import json
 import logging
 from datetime import timedelta
 
@@ -7,9 +8,12 @@
 from django.conf import settings
 from django.contrib import admin, messages
 from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
+from django.core.serializers.json import DjangoJSONEncoder
 from django.shortcuts import redirect
 from django.template.response import TemplateResponse
+from django.templatetags.static import static
 from django.urls import resolve, reverse
+from django.utils.html import format_html
 from django.utils.safestring import mark_safe
 from django.utils.timezone import localtime
 from django.utils.translation import get_language
@@ -28,6 +32,8 @@
 )
 from .hardware import REVERSE_FIRMWARE_IMAGE_MAP
 from .swapper import load_model
+from .utils import get_upgrader_schema_for_device
+from .widgets import FirmwareSchemaWidget
 
 logger = logging.getLogger(__name__)
 BatchUpgradeOperation = load_model('BatchUpgradeOperation')
@@ -86,6 +92,27 @@ def has_change_permission(self, request, obj=None):
         return True
 
 
+class BatchUpgradeConfirmationForm(forms.ModelForm):
+    upgrade_options = forms.JSONField(widget=FirmwareSchemaWidget(), required=False)
+    build = forms.ModelChoiceField(
+        widget=forms.HiddenInput(), required=False, queryset=Build.objects.all()
+    )
+
+    class Meta:
+        model = BatchUpgradeOperation
+        fields = ('build', 'upgrade_options')
+
+    @property
+    def media(self):
+        media = super().media
+        js = list(media._js) + [
+            'firmware-upgrader/js/upgrade-selected-confirmation.js',
+        ]
+        css = media._css.copy()
+        css['all'] += ['firmware-upgrader/css/upgrade-selected-confirmation.css']
+        return forms.Media(js=js, css=css)
+
+
 @admin.register(load_model('Build'))
 class BuildAdmin(BaseAdmin):
     list_display = ['__str__', 'organization', 'category', 'created', 'modified']
@@ -125,26 +152,41 @@ def upgrade_selected(self, request, queryset):
             return None
         upgrade_all = request.POST.get('upgrade_all')
         upgrade_related = request.POST.get('upgrade_related')
+        upgrade_options = request.POST.get('upgrade_options')
+        form = BatchUpgradeConfirmationForm()
         build = queryset.first()
         # upgrade has been confirmed
         if upgrade_all or upgrade_related:
-            batch = build.batch_upgrade(firmwareless=upgrade_all)
-            text = _(
-                'You can track the progress of this mass upgrade operation '
-                'in this page. Refresh the page from time to time to check '
-                'its progress.'
-            )
-            self.message_user(request, mark_safe(text), messages.SUCCESS)
-            url = reverse(
-                f'admin:{app_label}_batchupgradeoperation_change', args=[batch.pk]
+            form = BatchUpgradeConfirmationForm(
+                data={'upgrade_options': upgrade_options, 'build': build}
             )
-            return redirect(url)
+            form.full_clean()
+            if not form.errors:
+                upgrade_options = form.cleaned_data['upgrade_options']
+                batch = build.batch_upgrade(
+                    firmwareless=upgrade_all, upgrade_options=upgrade_options
+                )
+                text = _(
+                    'You can track the progress of this mass upgrade operation '
+                    'in this page. Refresh the page from time to time to check '
+                    'its progress.'
+                )
+                self.message_user(request, mark_safe(text), messages.SUCCESS)
+                url = reverse(
+                    f'admin:{app_label}_batchupgradeoperation_change', args=[batch.pk]
+                )
+                return redirect(url)
         # upgrade needs to be confirmed
         result = BatchUpgradeOperation.dry_run(build=build)
         related_device_fw = result['device_firmwares']
         firmwareless_devices = result['devices']
         title = _('Confirm mass upgrade operation')
         context = self.admin_site.each_context(request)
+        upgrader_schema = BatchUpgradeOperation(build=build)._get_upgrader_schema(
+            related_device_fw=related_device_fw,
+            firmwareless_devices=firmwareless_devices,
+        )
+
         context.update(
             {
                 'title': title,
@@ -152,6 +194,10 @@ def upgrade_selected(self, request, queryset):
                 'related_count': len(related_device_fw),
                 'firmwareless_devices': firmwareless_devices,
                 'firmwareless_count': len(firmwareless_devices),
+                'form': form,
+                'firmware_upgrader_schema': json.dumps(
+                    upgrader_schema, cls=DjangoJSONEncoder
+                ),
                 'build': build,
                 'opts': opts,
                 'action_checkbox_name': ACTION_CHECKBOX_NAME,
@@ -200,9 +246,31 @@ def has_delete_permission(self, request, obj):
     def has_add_permission(self, request, obj):
         return False
 
+    class Media:
+        css = {'all': ['firmware-upgrader/css/upgrade-options.css']}
+
+
+class ReadonlyUpgradeOptionsMixin:
+    @admin.display(description=_('Upgrade options'))
+    def readonly_upgrade_options(self, obj):
+        upgrader_schema = obj.upgrader_schema
+        if not upgrader_schema:
+            return _('Upgrade options are not supported for this upgrader.')
+        options = []
+        for key, value in upgrader_schema['properties'].items():
+            option_used = 'yes' if obj.upgrade_options.get(key, False) else 'no'
+            option_title = value['title']
+            icon_url = static(f'admin/img/icon-{option_used}.svg')
+            options.append(
+                f'<li><img src="{icon_url}" alt="{option_used}">{option_title}</li>'
+            )
+        return format_html(
+            mark_safe(f'<ul class="readonly-upgrade-options">{"".join(options)}</ul>')
+        )
+
 
 @admin.register(BatchUpgradeOperation)
-class BatchUpgradeOperationAdmin(ReadOnlyAdmin, BaseAdmin):
+class BatchUpgradeOperationAdmin(ReadonlyUpgradeOptionsMixin, ReadOnlyAdmin, BaseAdmin):
     list_display = ['build', 'organization', 'status', 'created', 'modified']
     list_filter = [
         BuildCategoryOrganizationFilter,
@@ -220,11 +288,18 @@ class BatchUpgradeOperationAdmin(ReadOnlyAdmin, BaseAdmin):
         'success_rate',
         'failed_rate',
         'aborted_rate',
+        'readonly_upgrade_options',
         'created',
         'modified',
     ]
     autocomplete_fields = ['build']
-    readonly_fields = ['completed', 'success_rate', 'failed_rate', 'aborted_rate']
+    readonly_fields = [
+        'completed',
+        'success_rate',
+        'failed_rate',
+        'aborted_rate',
+        'readonly_upgrade_options',
+    ]
 
     def organization(self, obj):
         return obj.build.category.organization
@@ -259,10 +334,16 @@ def __get_rate(self, value):
 
 
 class DeviceFirmwareForm(forms.ModelForm):
+    upgrade_options = forms.JSONField(widget=FirmwareSchemaWidget, required=False)
+
     class Meta:
         model = DeviceFirmware
         fields = '__all__'
 
+    class Media:
+        js = ['admin/js/jquery.init.js', 'firmware-upgrader/js/device-firmware.js']
+        css = {'all': ['firmware-upgrader/css/device-firmware.css']}
+
     def _get_image_queryset(self, device):
         # restrict firmware images to organization of the current device
         qs = (
@@ -287,6 +368,29 @@ def __init__(self, device, *args, **kwargs):
         super().__init__(*args, **kwargs)
         self.fields['image'].queryset = self._get_image_queryset(device)
 
+    def full_clean(self):
+        super().full_clean()
+        if not self.errors and hasattr(self, 'cleaned_data'):
+            upgrade_op = UpgradeOperation(
+                device=self.cleaned_data['device'],
+                image=self.cleaned_data['image'],
+                upgrade_options=self.cleaned_data['upgrade_options'],
+            )
+            try:
+                upgrade_op.full_clean()
+            except forms.ValidationError as error:
+                self.add_error('__all__', error.messages[0])
+
+    def save(self, commit=True):
+        """
+        Adapted from ModelForm.save()
+        Passes "upgrade_options to DeviceFirmware.save()
+        """
+        if commit:
+            # If committing, save the instance and the m2m data immediately.
+            self.instance.save(upgrade_options=self.cleaned_data['upgrade_options'])
+        return self.instance
+
 
 class DeviceFormSet(forms.BaseInlineFormSet):
     def get_form_kwargs(self, index):
@@ -306,6 +410,7 @@ class DeviceFirmwareInline(MultitenantAdminMixin, admin.StackedInline):
     verbose_name_plural = verbose_name
     extra = 0
     multitenant_shared_relations = ['device']
+    template = 'admin/firmware_upgrader/device_firmware_inline.html'
     # hack for openwisp-monitoring integartion
     # TODO: remove when this issue solved:
     # https://github.com/theatlantic/django-nested-admin/issues/128#issuecomment-665833142
@@ -314,6 +419,13 @@ class DeviceFirmwareInline(MultitenantAdminMixin, admin.StackedInline):
     def _get_conditional_queryset(self, request, obj, select_related=False):
         return bool(obj)
 
+    def get_formset(self, request, obj=None, **kwargs):
+        formset = super().get_formset(request, obj=obj, **kwargs)
+        if obj:
+            schema = get_upgrader_schema_for_device(obj)
+            formset.extra_context = json.dumps(schema, cls=DjangoJSONEncoder)
+        return formset
+
 
 class DeviceUpgradeOperationForm(UpgradeOperationForm):
     class Meta(UpgradeOperationForm.Meta):
@@ -324,7 +436,7 @@ def __init__(self, device, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
 
-class DeviceUpgradeOperationInline(UpgradeOperationInline):
+class DeviceUpgradeOperationInline(ReadonlyUpgradeOptionsMixin, UpgradeOperationInline):
     verbose_name = _('Recent Firmware Upgrades')
     verbose_name_plural = verbose_name
     formset = DeviceFormSet
@@ -333,6 +445,15 @@ class DeviceUpgradeOperationInline(UpgradeOperationInline):
     # TODO: remove when this issue solved:
     # https://github.com/theatlantic/django-nested-admin/issues/128#issuecomment-665833142
     sortable_options = {'disabled': True}
+    fields = [
+        'device',
+        'image',
+        'status',
+        'log',
+        'readonly_upgrade_options',
+        'modified',
+    ]
+    readonly_fields = fields
 
     def get_queryset(self, request, select_related=True):
         """
diff --git a/openwisp_firmware_upgrader/base/models.py b/openwisp_firmware_upgrader/base/models.py
index 76c86cd1..6d1b350c 100644
--- a/openwisp_firmware_upgrader/base/models.py
+++ b/openwisp_firmware_upgrader/base/models.py
@@ -2,12 +2,12 @@
 import os
 from decimal import Decimal
 
+import jsonschema
 import swapper
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.db import models, transaction
 from django.utils import timezone
 from django.utils.functional import cached_property
-from django.utils.module_loading import import_string
 from django.utils.translation import gettext_lazy as _
 from private_storage.fields import PrivateFileField
 
@@ -17,6 +17,7 @@
 
 from .. import settings as app_settings
 from ..exceptions import (
+    FirmwareUpgradeOptionsException,
     ReconnectionFailed,
     RecoverableFailure,
     UpgradeAborted,
@@ -34,10 +35,40 @@
     create_device_firmware,
     upgrade_firmware,
 )
+from ..utils import (
+    get_upgrader_class_for_device,
+    get_upgrader_class_from_device_connection,
+    get_upgrader_schema_for_device,
+)
 
 logger = logging.getLogger(__name__)
 
 
+class UpgradeOptionsMixin(models.Model):
+    upgrade_options = models.JSONField(default=dict, blank=True)
+
+    class Meta:
+        abstract = True
+
+    def validate_upgrade_options(self):
+        if not self.upgrade_options:
+            return
+        if not getattr(self.upgrader_class, 'SCHEMA'):
+            raise ValidationError(
+                _('Using upgrade options is not allowed with this upgrader.')
+            )
+        try:
+            self.upgrader_class.validate_upgrade_options(self.upgrade_options)
+        except jsonschema.ValidationError:
+            raise ValidationError('The upgrade options are invalid')
+        except FirmwareUpgradeOptionsException as error:
+            raise ValidationError(*error.args)
+
+    def clean(self):
+        super().clean()
+        self.validate_upgrade_options()
+
+
 class AbstractCategory(OrgMixin, TimeStampedEditableModel):
     name = models.CharField(max_length=64, db_index=True)
     description = models.TextField(blank=True)
@@ -122,8 +153,11 @@ def clean(self):
                 }
             )
 
-    def batch_upgrade(self, firmwareless):
-        batch = load_model('BatchUpgradeOperation')(build=self)
+    def batch_upgrade(self, firmwareless, upgrade_options=None):
+        upgrade_options = upgrade_options or {}
+        batch = load_model('BatchUpgradeOperation')(
+            build=self, upgrade_options=upgrade_options
+        )
         batch.full_clean()
         batch.save()
         transaction.on_commit(
@@ -284,13 +318,13 @@ def clean(self):
     def image_has_changed(self):
         return self._state.adding or self.image_id != self._old_image.id
 
-    def save(self, batch=None, upgrade=True, *args, **kwargs):
+    def save(self, batch=None, upgrade=True, upgrade_options=None, *args, **kwargs):
         # if firwmare image has changed launch upgrade
         # upgrade won't be launched the first time
         if upgrade and (self.image_has_changed or not self.installed):
             self.installed = False
             super().save(*args, **kwargs)
-            self.create_upgrade_operation(batch)
+            self.create_upgrade_operation(batch, upgrade_options=upgrade_options or {})
         else:
             super().save(*args, **kwargs)
         self._update_old_image()
@@ -299,9 +333,11 @@ def _update_old_image(self):
         if hasattr(self, 'image'):
             self._old_image = self.image
 
-    def create_upgrade_operation(self, batch):
+    def create_upgrade_operation(self, batch, upgrade_options=None):
         uo_model = load_model('UpgradeOperation')
-        operation = uo_model(device=self.device, image=self.image)
+        operation = uo_model(
+            device=self.device, image=self.image, upgrade_options=upgrade_options
+        )
         if batch:
             operation.batch = batch
         operation.full_clean()
@@ -366,7 +402,7 @@ def auto_create_device_firmwares(cls, instance, created, **kwargs):
             )
 
 
-class AbstractBatchUpgradeOperation(TimeStampedEditableModel):
+class AbstractBatchUpgradeOperation(UpgradeOptionsMixin, TimeStampedEditableModel):
     build = models.ForeignKey(get_model_name('Build'), on_delete=models.CASCADE)
     STATUS_CHOICES = (
         ('idle', _('idle')),
@@ -426,7 +462,7 @@ def upgrade_related_devices(self):
             if image:
                 device_fw.image = image
                 device_fw.full_clean()
-                device_fw.save(self)
+                device_fw.save(self, upgrade_options=self.upgrade_options)
 
     def upgrade_firmwareless_devices(self):
         """
@@ -442,7 +478,7 @@ def upgrade_firmwareless_devices(self):
                 DeviceFirmware = load_model('DeviceFirmware')
                 device_fw = DeviceFirmware(device=device, image=image)
                 device_fw.full_clean()
-                device_fw.save(self)
+                device_fw.save(self, upgrade_options=self.upgrade_options)
 
     @cached_property
     def upgrade_operations(self):
@@ -478,12 +514,41 @@ def aborted_rate(self):
         aborted = self.upgrade_operations.filter(status='aborted').count()
         return self.__get_rate(aborted)
 
+    @property
+    def upgrader_class(self):
+        return self._get_upgrader_class()
+
+    @property
+    def upgrader_schema(self):
+        return self._get_upgrader_schema()
+
+    def _get_upgrader_class(self, related_device_fw=None, firmwareless_devices=None):
+        if self.upgrade_operations:
+            return get_upgrader_class_for_device(self.upgrade_operations[0].device)
+        related_device_fw = (
+            related_device_fw
+            or self.build._find_related_device_firmwares(select_devices=True)
+        )
+        if related_device_fw:
+            return get_upgrader_class_for_device(related_device_fw.first().device)
+        firmwareless_devices = (
+            firmwareless_devices or self.build._find_firmwareless_devices()
+        )
+        if firmwareless_devices:
+            return get_upgrader_class_for_device(firmwareless_devices.first())
+
+    def _get_upgrader_schema(self, related_device_fw=None, firmwareless_devices=None):
+        upgrader_class = self._get_upgrader_class(
+            related_device_fw, firmwareless_devices
+        )
+        return getattr(upgrader_class, 'SCHEMA', None)
+
     def __get_rate(self, number):
         result = Decimal(number) / Decimal(self.total_operations) * 100
         return round(result, 2)
 
 
-class AbstractUpgradeOperation(TimeStampedEditableModel):
+class AbstractUpgradeOperation(UpgradeOptionsMixin, TimeStampedEditableModel):
     STATUS_CHOICES = (
         ('in-progress', _('in progress')),
         ('success', _('success')),
@@ -575,11 +640,8 @@ def upgrade(self, recoverable=True):
             self.status = 'aborted'
             self.save()
             return
-        try:
-            upgrader_class = app_settings.UPGRADERS_MAP[conn.update_strategy]
-            upgrader_class = import_string(upgrader_class)
-        except (AttributeError, ImportError) as e:
-            logger.exception(e)
+        upgrader_class = get_upgrader_class_from_device_connection(conn)
+        if not upgrader_class:
             return
         upgrader = upgrader_class(self, conn)
         try:
@@ -633,3 +695,11 @@ def save(self, *args, **kwargs):
         if self.batch and self.status != 'in-progress':
             self.batch.update()
         return result
+
+    @property
+    def upgrader_schema(self):
+        return get_upgrader_schema_for_device(self.device)
+
+    @property
+    def upgrader_class(self):
+        return get_upgrader_class_for_device(self.device)
diff --git a/openwisp_firmware_upgrader/exceptions.py b/openwisp_firmware_upgrader/exceptions.py
index a0c54be6..c473feb9 100644
--- a/openwisp_firmware_upgrader/exceptions.py
+++ b/openwisp_firmware_upgrader/exceptions.py
@@ -2,6 +2,10 @@ class FirmwareUpgraderException(Exception):
     pass
 
 
+class FirmwareUpgradeOptionsException(Exception):
+    pass
+
+
 class UpgradeNotNeeded(FirmwareUpgraderException):
     """
     Raised when the upgrade is not needed
diff --git a/openwisp_firmware_upgrader/migrations/0009_upgrade_options.py b/openwisp_firmware_upgrader/migrations/0009_upgrade_options.py
new file mode 100644
index 00000000..801c86ad
--- /dev/null
+++ b/openwisp_firmware_upgrader/migrations/0009_upgrade_options.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.18 on 2023-03-03 21:37
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('firmware_upgrader', '0008_firmware_image_baseurl'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='batchupgradeoperation',
+            name='upgrade_options',
+            field=models.JSONField(default=dict, blank=True),
+        ),
+        migrations.AddField(
+            model_name='upgradeoperation',
+            name='upgrade_options',
+            field=models.JSONField(default=dict, blank=True),
+        ),
+    ]
diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/device-firmware.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/device-firmware.css
new file mode 100644
index 00000000..6c2d5510
--- /dev/null
+++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/device-firmware.css
@@ -0,0 +1,18 @@
+#id_devicefirmware-0-upgrade_options_jsoneditor {
+  padding: 15px;
+  border-bottom: 1px solid var(--hairline-color);
+}
+#id_devicefirmware-0-upgrade_options_jsoneditor label {
+  width: unset;
+  padding: 0px;
+}
+#id_devicefirmware-0-upgrade_options_jsoneditor input[type="checkbox"] {
+  vertical-align: middle;
+}
+#main #id_devicefirmware-0-upgrade_options_jsoneditor .form-row {
+  padding: 0px;
+  border-bottom: none;
+}
+#devicefirmware-group .form-row.field-upgrade_options {
+  display: none;
+}
diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css
new file mode 100644
index 00000000..f985754b
--- /dev/null
+++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-options.css
@@ -0,0 +1,10 @@
+#main div[id*=upgrade_options_jsoneditor] .controls {
+  display: none;
+}
+.field-readonly_upgrade_options .readonly-upgrade-options {
+  margin-left: 0px;
+  padding-left: 0px;
+}
+.readonly-upgrade-options li img {
+  margin-right: 5px;
+}
diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css
new file mode 100644
index 00000000..624db90d
--- /dev/null
+++ b/openwisp_firmware_upgrader/static/firmware-upgrader/css/upgrade-selected-confirmation.css
@@ -0,0 +1,15 @@
+form > div > div.form-row {
+  display: none;
+}
+form .errorlist {
+  margin-top: -10px;
+}
+.errorlist > li {
+  visibility: hidden;
+}
+.errorlist ul li{
+  visibility: visible;
+}
+#ow-loading {
+  display: flex;
+}
diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/device-firmware.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/device-firmware.js
new file mode 100644
index 00000000..24a7e863
--- /dev/null
+++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/device-firmware.js
@@ -0,0 +1,37 @@
+'use strict';
+
+django.jQuery(function ($) {
+    if(firmwareUpgraderSchema === null) {
+        return;
+    }
+    var firmwareImageChanged = false;
+    // Do not render JSONSchema form if the image field is not changed.
+    // The "change" event is also emitted when the form is rendered.
+    // The "firmwareImageChanged" variable is used as flag to prevent this
+    // behavior.
+    if (
+        $('#id_devicefirmware-0-upgrade_options').val() && $('#id_devicefirmware-0-upgrade_options').val() !== 'null'
+    ) {
+        firmwareImageChanged = true;
+    }
+    $('#devicefirmware-group').on('change', '#id_devicefirmware-0-image', function (event) {
+        if (!$(event.target).val()) {
+            $('#id_devicefirmware-0-upgrade_options_jsoneditor').hide();
+            return;
+        }
+        $('#id_devicefirmware-0-upgrade_options_jsoneditor').show();
+        if (firmwareImageChanged) {
+            django._loadJsonSchemaUi(
+                $('#id_devicefirmware-0-upgrade_options').get(0),
+                false,
+                firmwareUpgraderSchema,
+                true
+            );
+        } else {
+            firmwareImageChanged = true;
+        }
+    });
+    $('#devicefirmware-group .add-row a').click(function() {
+        firmwareImageChanged = true;
+    });
+});
diff --git a/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js
new file mode 100644
index 00000000..9369a476
--- /dev/null
+++ b/openwisp_firmware_upgrader/static/firmware-upgrader/js/upgrade-selected-confirmation.js
@@ -0,0 +1,16 @@
+'use strict';
+
+django.jQuery(function ($) {
+    if(firmwareUpgraderSchema === null) {
+        $('.form-row').hide();
+
+    } else {
+        django._loadJsonSchemaUi(
+            $('textarea[name="upgrade_options"]').get(0),
+            false,
+            firmwareUpgraderSchema,
+            true
+        );
+    }
+    $('#ow-loading').hide();
+});
diff --git a/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html
new file mode 100644
index 00000000..9c6d2255
--- /dev/null
+++ b/openwisp_firmware_upgrader/templates/admin/firmware_upgrader/device_firmware_inline.html
@@ -0,0 +1,5 @@
+{% include "admin/edit_inline/stacked.html" %}
+
+<script>
+var firmwareUpgraderSchema = {{ inline_admin_formset.formset.extra_context | safe }}
+</script>
diff --git a/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html b/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html
index 3f435257..31aef52d 100644
--- a/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html
+++ b/openwisp_firmware_upgrader/templates/admin/upgrade_selected_confirmation.html
@@ -47,6 +47,10 @@ <h2>{% trans "Devices which the system has never upgraded yet" %} ({{ firmwarele
   <input type="hidden" name="{{ action_checkbox_name }}" value="{{ build.pk|unlocalize }}">
   <input type="hidden" name="action" value="upgrade_selected">
   {% if firmwareless_count or related_count %}
+  <div>{% if form.errors %}{{ form.errors }} {% endif %}</div>
+  <div class="form-row{% if form.errors.upgrade_options %} errors{% endif %}">
+    {{ form }}
+  </div>
   <input type="submit" name="upgrade_all" value="{% trans "Upgrade all devices" %}">
   {% if firmwareless_count and related_count %}
   <input type="submit" name="upgrade_related" value="{% trans "Upgrade only related devices" %}">
@@ -57,6 +61,15 @@ <h2>{% trans "Devices which the system has never upgraded yet" %} ({{ firmwarele
   {% else %}
   <a href="#" class="button cancel-link">{% trans "Go back" %}</a>
   {% endif %}
+
   </div>
   </form>
 {% endblock %}
+
+{% block footer %}
+{{ block.super }}
+<script>
+    var firmwareUpgraderSchema = {{ firmware_upgrader_schema | safe }}
+</script>
+{{ form.media }}
+{% endblock footer %}
diff --git a/openwisp_firmware_upgrader/upgraders/openwrt.py b/openwisp_firmware_upgrader/upgraders/openwrt.py
index 5d03b172..69aa73e2 100644
--- a/openwisp_firmware_upgrader/upgraders/openwrt.py
+++ b/openwisp_firmware_upgrader/upgraders/openwrt.py
@@ -3,6 +3,7 @@
 from hashlib import sha256
 from time import sleep
 
+import jsonschema
 from billiard import Process, Queue
 from django.utils.translation import gettext_lazy as _
 from paramiko.ssh_exception import NoValidConnectionsError, SSHException
@@ -10,6 +11,7 @@
 from openwisp_controller.connection.connectors.openwrt.ssh import OpenWrt as BaseOpenWrt
 
 from ..exceptions import (
+    FirmwareUpgradeOptionsException,
     ReconnectionFailed,
     RecoverableFailure,
     UpgradeAborted,
@@ -25,9 +27,63 @@ class OpenWrt(BaseOpenWrt):
     RECONNECT_RETRY_DELAY = OPENWRT_SETTINGS.get('reconnect_retry_delay', 20)
     RECONNECT_MAX_RETRIES = OPENWRT_SETTINGS.get('reconnect_max_retries', 35)
     UPGRADE_TIMEOUT = OPENWRT_SETTINGS.get('upgrade_timeout', 90)
-    UPGRADE_COMMAND = '{sysupgrade} -v -c {path}'
+    UPGRADE_COMMAND = '{sysupgrade} -v {flags} {path}'
     # path to sysupgrade command
     _SYSUPGRADE = '/sbin/sysupgrade'
+    SCHEMA = {
+        'type': 'object',
+        'properties': {
+            'c': {
+                'type': 'boolean',
+                'title': _('Attempt to preserve all changed files in /etc/ (-c)'),
+                'format': 'checkbox',
+                'default': True,
+            },
+            'o': {
+                'type': 'boolean',
+                'title': _(
+                    'Attempt to preserve all changed files in /, except those from '
+                    'packages but including changed confs. (-o)'
+                ),
+                'format': 'checkbox',
+            },
+            'n': {
+                'type': 'boolean',
+                'title': _('Do not save configuration over reflash (-n)'),
+                'format': 'checkbox',
+            },
+            'u': {
+                'type': 'boolean',
+                'title': _(
+                    'Skip from backup files that are equal to those in /rom (-u)'
+                ),
+                'format': 'checkbox',
+            },
+            'p': {
+                'type': 'boolean',
+                'title': _(
+                    'Do not attempt to restore the partition table after flash. (-p)'
+                ),
+                'format': 'checkbox',
+            },
+            'k': {
+                'type': 'boolean',
+                'title': _(
+                    'Include in backup a list of current installed packages at'
+                    ' /etc/backup/installed_packages.txt (-k)'
+                ),
+                'format': 'checkbox',
+            },
+            'F': {
+                'type': 'boolean',
+                'title': _(
+                    'Flash image even if image checks fail, this is dangerous! (-F)'
+                ),
+                'format': 'checkbox',
+            },
+        },
+        'additionalProperties': False,
+    }
 
     log_lines = None
 
@@ -40,6 +96,27 @@ def __init__(self, upgrade_operation, connection):
         self.connection = connection
         self._non_critical_services_stopped = False
 
+    @classmethod
+    def validate_upgrade_options(cls, upgrade_options):
+        jsonschema.Draft4Validator(cls.SCHEMA).validate(upgrade_options)
+        if upgrade_options.get('n', False):
+            if upgrade_options.get('o', False):
+                raise FirmwareUpgradeOptionsException(
+                    {
+                        'upgrade_options': _(
+                            'The "-n" and "-o" options cannot be used together'
+                        )
+                    }
+                )
+            if upgrade_options.get('c', False):
+                raise FirmwareUpgradeOptionsException(
+                    {
+                        'upgrade_options': _(
+                            'The "-n" and "-c" options cannot be used together'
+                        )
+                    }
+                )
+
     def log(self, value, save=True):
         self.upgrade_operation.log_line(value, save=save)
 
@@ -193,7 +270,21 @@ def get_remote_path(self, image):
         return os.path.join(self.REMOTE_UPLOAD_DIR, filename)
 
     def get_upgrade_command(self, path):
-        return self.UPGRADE_COMMAND.format(sysupgrade=self._SYSUPGRADE, path=path)
+        # Build flags for the command
+        flags = []
+        for key, value in self.upgrade_operation.upgrade_options.items():
+            if value is True:
+                flags.append(f'-{key}')
+        # Enforce defaults
+        for key, property in self.SCHEMA['properties'].items():
+            if self.upgrade_operation.upgrade_options.get(
+                key, None
+            ) is None and property.get('default'):
+                flags.append(f'-{key}')
+        flags = ' '.join(flags)
+        return self.UPGRADE_COMMAND.format(
+            sysupgrade=self._SYSUPGRADE, flags=flags, path=path
+        )
 
     def _test_checksum(self, image):
         """
diff --git a/openwisp_firmware_upgrader/utils.py b/openwisp_firmware_upgrader/utils.py
new file mode 100644
index 00000000..f53034e2
--- /dev/null
+++ b/openwisp_firmware_upgrader/utils.py
@@ -0,0 +1,41 @@
+import logging
+
+from django.utils.module_loading import import_string
+
+from . import settings as app_settings
+
+logger = logging.getLogger(__name__)
+
+
+def get_upgrader_schema_for_device(device):
+    upgrader_class = get_upgrader_class_for_device(device)
+    return getattr(upgrader_class, 'SCHEMA', None)
+
+
+def get_upgrader_class_for_device(device):
+    """
+    Returns firmware upgrader class for a device depending
+    on update_strategy of device's DeviceConnection.
+
+    It only takes the first DeviceConnection object into consideration.
+    This function makes the following assumptions:
+        - a device cannot have DeviceConnection objects of
+          two different update_strategy.
+        - an upgrade cannot be performed on a device without a
+          device connection
+    """
+    device_conn = device.deviceconnection_set.filter(
+        update_strategy__icontains='ssh',
+        enabled=True,
+    ).first()
+    return get_upgrader_class_from_device_connection(device_conn)
+
+
+def get_upgrader_class_from_device_connection(device_conn):
+    try:
+        upgrader_class = app_settings.UPGRADERS_MAP[device_conn.update_strategy]
+        upgrader_class = import_string(upgrader_class)
+    except (AttributeError, ImportError, KeyError) as e:
+        logger.exception(e)
+        return
+    return upgrader_class
diff --git a/openwisp_firmware_upgrader/widgets.py b/openwisp_firmware_upgrader/widgets.py
new file mode 100644
index 00000000..91998d91
--- /dev/null
+++ b/openwisp_firmware_upgrader/widgets.py
@@ -0,0 +1,28 @@
+from django import forms
+
+from openwisp_controller.config.widgets import JsonSchemaWidget as BaseJsonSchemaWidget
+
+from .swapper import load_model
+
+UpgradeOperation = load_model('UpgradeOperation')
+DeviceFirmware = load_model('DeviceFirmware')
+
+
+class FirmwareSchemaWidget(BaseJsonSchemaWidget):
+    schema_view_name = None
+    app_label_model = (
+        f'{UpgradeOperation._meta.app_label}_{UpgradeOperation._meta.model_name}'
+    )
+    netjsonconfig_hint = False
+    advanced_mode = False
+    extra_attrs = {
+        'data-show-errors': 'never',
+        'class': 'manual',
+    }
+
+    @property
+    def media(self):
+        media = super().media
+        css = media._css.copy()
+        css['all'] += ['firmware-upgrader/css/upgrade-options.css']
+        return forms.Media(js=media._js, css=css)


Test patch:
diff --git a/openwisp_firmware_upgrader/tests/test_admin.py b/openwisp_firmware_upgrader/tests/test_admin.py
index 95cd9c2c..3aeea58e 100644
--- a/openwisp_firmware_upgrader/tests/test_admin.py
+++ b/openwisp_firmware_upgrader/tests/test_admin.py
@@ -1,3 +1,4 @@
+import json
 from datetime import timedelta
 from unittest import mock
 
@@ -9,6 +10,7 @@
 from django.utils.timezone import localtime
 
 from openwisp_controller.config.tests.test_admin import TestAdmin as TestConfigAdmin
+from openwisp_controller.connection import settings as conn_settings
 from openwisp_firmware_upgrader.admin import (
     BuildAdmin,
     DeviceAdmin,
@@ -22,6 +24,7 @@
 
 from ..hardware import REVERSE_FIRMWARE_IMAGE_MAP
 from ..swapper import load_model
+from ..upgraders.openwisp import OpenWisp1
 from .base import TestUpgraderMixin
 
 User = get_user_model()
@@ -109,7 +112,7 @@ def test_upgrade_selected_error(self):
     def test_upgrade_intermediate_page_related(self):
         self._login()
         env = self._create_upgrade_env()
-        with self.assertNumQueries(11):
+        with self.assertNumQueries(13):
             r = self.client.post(
                 self.build_list_url,
                 {
@@ -125,7 +128,7 @@ def test_upgrade_intermediate_page_related(self):
     def test_upgrade_intermediate_page_firmwareless(self):
         self._login()
         env = self._create_upgrade_env(device_firmware=False)
-        with self.assertNumQueries(9):
+        with self.assertNumQueries(12):
             r = self.client.post(
                 self.build_list_url,
                 {
@@ -335,19 +338,39 @@ def test_upgrade_related(self, *args):
         self.assertEqual(Device.objects.count(), 3)
         self.assertEqual(UpgradeOperation.objects.count(), 0)
         self.assertEqual(fw.count(), 0)
-        r = self.client.post(
-            self.build_list_url,
-            {
-                'action': 'upgrade_selected',
-                'upgrade_related': 'upgrade_related',
-                ACTION_CHECKBOX_NAME: (env['build2'].pk,),
-            },
-            follow=True,
-        )
-        self.assertContains(r, '<li class="success">')
-        self.assertContains(r, 'track the progress')
-        self.assertEqual(UpgradeOperation.objects.count(), 2)
-        self.assertEqual(fw.count(), 2)
+
+        with self.subTest('Invalid upgrade_options'):
+            response = self.client.post(
+                self.build_list_url,
+                {
+                    'action': 'upgrade_selected',
+                    'upgrade_related': 'upgrade_related',
+                    'upgrade_options': 'invalid',
+                    ACTION_CHECKBOX_NAME: (env['build2'].pk,),
+                },
+                follow=True,
+            )
+            self.assertContains(
+                response, '<ul class="errorlist"><li>Enter a valid JSON.</li></ul>'
+            )
+
+        with self.subTest('Test with valid upgrade_options'):
+            r = self.client.post(
+                self.build_list_url,
+                {
+                    'action': 'upgrade_selected',
+                    'upgrade_related': 'upgrade_related',
+                    'upgrade_options': '{"c": true}',
+                    ACTION_CHECKBOX_NAME: (env['build2'].pk,),
+                },
+                follow=True,
+            )
+            self.assertContains(r, '<li class="success">')
+            self.assertContains(r, 'track the progress')
+            self.assertEqual(
+                UpgradeOperation.objects.filter(upgrade_options={'c': True}).count(), 2
+            )
+            self.assertEqual(fw.count(), 2)
 
     def test_upgrade_all(self, *args):
         self._login()
@@ -360,19 +383,63 @@ def test_upgrade_all(self, *args):
         self.assertEqual(Device.objects.count(), 3)
         self.assertEqual(UpgradeOperation.objects.count(), 0)
         self.assertEqual(fw.count(), 0)
-        r = self.client.post(
-            self.build_list_url,
-            {
-                'action': 'upgrade_selected',
-                'upgrade_all': 'upgrade_all',
-                ACTION_CHECKBOX_NAME: (env['build2'].pk,),
-            },
-            follow=True,
-        )
-        self.assertContains(r, '<li class="success">')
-        self.assertContains(r, 'track the progress')
-        self.assertEqual(UpgradeOperation.objects.count(), 3)
-        self.assertEqual(fw.count(), 3)
+
+        with self.subTest('Invalid upgrade_options'):
+            response = self.client.post(
+                self.build_list_url,
+                {
+                    'action': 'upgrade_selected',
+                    'upgrade_all': 'upgrade_all',
+                    'upgrade_options': 'invalid',
+                    ACTION_CHECKBOX_NAME: (env['build2'].pk,),
+                },
+                follow=True,
+            )
+            self.assertEqual(response.status_code, 200)
+            self.assertContains(
+                response, '<ul class="errorlist"><li>Enter a valid JSON.</li></ul>'
+            )
+
+        with self.subTest('Test with valid upgrade_options'):
+            response = self.client.post(
+                self.build_list_url,
+                {
+                    'action': 'upgrade_selected',
+                    'upgrade_all': 'upgrade_all',
+                    'upgrade_options': '{"c": true}',
+                    ACTION_CHECKBOX_NAME: (env['build2'].pk,),
+                },
+                follow=True,
+            )
+            self.assertContains(response, '<li class="success">')
+            self.assertContains(response, 'track the progress')
+            self.assertEqual(
+                UpgradeOperation.objects.filter(upgrade_options={'c': True}).count(), 3
+            )
+            self.assertEqual(fw.count(), 3)
+            self.assertContains(
+                response,
+                (
+                    '<div class="readonly"><ul class="readonly-upgrade-options">'
+                    '<li><img src="/static/admin/img/icon-yes.svg" alt="yes">'
+                    'Attempt to preserve all changed files in /etc/ (-c)</li>'
+                    '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                    'Attempt to preserve all changed files in /, except those from '
+                    'packages but including changed confs. (-o)</li>'
+                    '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                    'Do not save configuration over reflash (-n)</li>'
+                    '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                    'Skip from backup files that are equal to those in /rom (-u)</li>'
+                    '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                    'Do not attempt to restore the partition table after flash. (-p)</li>'
+                    '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                    'Include in backup a list of current installed packages at '
+                    '/etc/backup/installed_packages.txt (-k)</li>'
+                    '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                    'Flash image even if image checks fail, this is dangerous! (-F)</li></ul></div>'
+                ),
+                html=True,
+            )
 
     def test_massive_upgrade_operation_page(self, *args):
         self.test_upgrade_all()
@@ -427,5 +494,165 @@ def test_upgrade_operation_inline_queryset(self, *args):
         qs = inline.get_queryset(request)
         self.assertEqual(qs.count(), 0)
 
+    def test_device_firmware_upgrade_options(self, *args):
+        self._login()
+        device_fw = self._create_device_firmware()
+        device = device_fw.device
+        device_conn = device.deviceconnection_set.first()
+        device_params = self._device_params.copy()
+        build = self._create_build(version='0.2')
+        image = self._create_firmware_image(build=build)
+        upgrade_options = {
+            'c': True,
+            'o': False,
+            'u': False,
+            'n': False,
+            'p': False,
+            'k': False,
+            'F': True,
+        }
+        device_params.update(
+            {
+                'model': device.model,
+                'devicefirmware-0-image': str(image.id),
+                'devicefirmware-0-id': str(device_fw.id),
+                'devicefirmware-0-upgrade_options': json.dumps(upgrade_options),
+                'organization': str(device.organization.id),
+                'config-0-id': str(device.config.pk),
+                'config-0-device': str(device.id),
+                'deviceconnection_set-0-credentials': str(device_conn.credentials_id),
+                'deviceconnection_set-0-id': str(device_conn.id),
+                'deviceconnection_set-0-update_strategy': conn_settings.DEFAULT_UPDATE_STRATEGIES[
+                    0
+                ][
+                    0
+                ],
+                'deviceconnection_set-0-enabled': True,
+                'devicefirmware-TOTAL_FORMS': 1,
+                'devicefirmware-INITIAL_FORMS': 1,
+                'upgradeoperation_set-TOTAL_FORMS': 0,
+                'upgradeoperation_set-INITIAL_FORMS': 0,
+                'upgradeoperation_set-MIN_NUM_FORMS': 0,
+                'upgradeoperation_set-MAX_NUM_FORMS': 0,
+                '_continue': True,
+            }
+        )
+        response = self.client.post(
+            reverse('admin:config_device_change', args=[device.id]),
+            data=device_params,
+            follow=True,
+        )
+        self.assertEqual(response.status_code, 200)
+        self.assertEqual(device.upgradeoperation_set.count(), 1)
+        upgrade_operation = device.upgradeoperation_set.first()
+        self.assertEqual(upgrade_operation.upgrade_options, upgrade_options)
+        self.assertContains(
+            response,
+            (
+                '<div class="readonly"><ul class="readonly-upgrade-options"><li>'
+                '<img src="/static/admin/img/icon-yes.svg" alt="yes">'
+                'Attempt to preserve all changed files in /etc/ (-c)</li>'
+                '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                'Attempt to preserve all changed files in /, except those from packages '
+                'but including changed confs. (-o)</li>'
+                '<li><img src="/static/admin/img/icon-no.svg" '
+                'alt="no">Do not save configuration over reflash (-n)</li>'
+                '<li><img src="/static/admin/img/icon-no.svg" alt="no">Skip from backup files '
+                'that are equal to those in /rom (-u)</li>'
+                '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                'Do not attempt to restore the partition table after flash. (-p)</li>'
+                '<li><img src="/static/admin/img/icon-no.svg" alt="no">'
+                'Include in backup a list of current installed packages at '
+                '/etc/backup/installed_packages.txt (-k)</li>'
+                '<li><img src="/static/admin/img/icon-yes.svg" alt="yes">'
+                'Flash image even if image checks fail, this is dangerous! (-F)</li></ul></div>'
+            ),
+            html=True,
+        )
+
+    @mock.patch.object(OpenWisp1, 'SCHEMA', None)
+    def test_using_upgrade_options_with_unsupported_upgrader(self, *args):
+        self._login()
+        device_fw = self._create_device_firmware()
+        device = device_fw.device
+        device.config.backend = 'netjsonconfig.OpenWisp'
+        device.config.save()
+        device_conn = device.deviceconnection_set.first()
+        device_conn.update_strategy = conn_settings.DEFAULT_UPDATE_STRATEGIES[1][0]
+        device_conn.save()
+        device_params = self._device_params.copy()
+        build = self._create_build(version='0.2')
+        image = self._create_firmware_image(build=build)
+        upgrade_options = {
+            'c': True,
+            'o': False,
+            'u': False,
+            'n': False,
+            'p': False,
+            'k': False,
+            'F': True,
+        }
+        device_params.update(
+            {
+                'model': device.model,
+                'devicefirmware-0-image': str(image.id),
+                'devicefirmware-0-id': str(device_fw.id),
+                'devicefirmware-0-upgrade_options': json.dumps(upgrade_options),
+                'organization': str(device.organization.id),
+                'config-0-id': str(device.config.pk),
+                'config-0-device': str(device.id),
+                'deviceconnection_set-0-credentials': str(device_conn.credentials_id),
+                'deviceconnection_set-0-id': str(device_conn.id),
+                'deviceconnection_set-0-update_strategy': (
+                    conn_settings.DEFAULT_UPDATE_STRATEGIES[1][0]
+                ),
+                'deviceconnection_set-0-enabled': True,
+                'devicefirmware-TOTAL_FORMS': 1,
+                'devicefirmware-INITIAL_FORMS': 1,
+                'upgradeoperation_set-TOTAL_FORMS': 0,
+                'upgradeoperation_set-INITIAL_FORMS': 0,
+                'upgradeoperation_set-MIN_NUM_FORMS': 0,
+                'upgradeoperation_set-MAX_NUM_FORMS': 0,
+                '_continue': True,
+            }
+        )
+
+        with self.subTest('Test DeviceFirmwareInline does not have schema defined'):
+            response = self.client.get(
+                reverse('admin:config_device_change', args=[device.id])
+            )
+            self.assertContains(
+                response, '<script>\nvar firmwareUpgraderSchema = null\n</script>'
+            )
+
+        with self.subTest('Test using upgrade options with unsupported upgrader'):
+            response = self.client.post(
+                reverse('admin:config_device_change', args=[device.id]),
+                data=device_params,
+                follow=True,
+            )
+            self.assertContains(
+                response,
+                (
+                    '<ul class="errorlist nonfield"><li>Using upgrade '
+                    'options is not allowed with this upgrader.</li></ul>'
+                ),
+            )
+
+        with self.subTest('Test upgrading without upgrade options'):
+            del device_params['devicefirmware-0-upgrade_options']
+            response = self.client.post(
+                reverse('admin:config_device_change', args=[device.id]),
+                data=device_params,
+                follow=True,
+            )
+            self.assertContains(
+                response,
+                (
+                    '<div class="readonly">Upgrade options are '
+                    'not supported for this upgrader.</div>'
+                ),
+            )
+
 
 del TestConfigAdmin
diff --git a/openwisp_firmware_upgrader/tests/test_models.py b/openwisp_firmware_upgrader/tests/test_models.py
index c688baf1..0b0dc8d6 100644
--- a/openwisp_firmware_upgrader/tests/test_models.py
+++ b/openwisp_firmware_upgrader/tests/test_models.py
@@ -197,6 +197,38 @@ def test_device_firmware_image_invalid_model(self):
         else:
             self.fail('ValidationError not raised')
 
+    def test_upgrade_operation_invalid_upgrade_options(self):
+        device_fw = self._create_device_firmware()
+        uo = UpgradeOperation(
+            device=device_fw.device,
+            image=device_fw.image,
+        )
+        with self.subTest('Test using invalid options'):
+            uo.upgrade_options = {'invalid': True}
+            with self.assertRaises(ValidationError) as error:
+                uo.full_clean()
+            self.assertEqual(
+                error.exception.message_dict['__all__'],
+                ['The upgrade options are invalid'],
+            )
+
+        with self.subTest('Test using mutually exclusive options'):
+            uo.upgrade_options = {'c': True, 'n': True}
+            with self.assertRaises(ValidationError) as error:
+                uo.full_clean()
+            self.assertEqual(
+                error.exception.message_dict['upgrade_options'],
+                ['The "-n" and "-c" options cannot be used together'],
+            )
+
+            uo.upgrade_options = {'o': True, 'n': True}
+            with self.assertRaises(ValidationError) as error:
+                uo.full_clean()
+            self.assertEqual(
+                error.exception.message_dict['upgrade_options'],
+                ['The "-n" and "-o" options cannot be used together'],
+            )
+
     def test_upgrade_operation_log_line(self):
         device_fw = self._create_device_firmware()
         uo = UpgradeOperation(device=device_fw.device, image=device_fw.image)
diff --git a/openwisp_firmware_upgrader/tests/test_openwrt_upgrader.py b/openwisp_firmware_upgrader/tests/test_openwrt_upgrader.py
index be7b17ae..37ed96d5 100644
--- a/openwisp_firmware_upgrader/tests/test_openwrt_upgrader.py
+++ b/openwisp_firmware_upgrader/tests/test_openwrt_upgrader.py
@@ -72,7 +72,7 @@ def mocked_exec_upgrade_success(command, exit_codes=None, timeout=None):
 
 
 def mocked_sysupgrade_failure(command, exit_codes=None, timeout=None):
-    if command.startswith(f'{OpenWrt._SYSUPGRADE} -v -c '):
+    if command.startswith(f'{OpenWrt._SYSUPGRADE} -v -c'):
         raise CommandFailedException(
             "Invalid image type\nImage check 'platform_check_image' failed."
         )
@@ -480,9 +480,26 @@ def test_openwrt_settings(self):
     @patch.object(OpenWrt, 'exec_command', side_effect=mocked_exec_upgrade_success)
     def test_get_upgrade_command(self, exec_command, is_alive, putfo):
         device_fw, device_conn, upgrade_op, output, _ = self._trigger_upgrade()
-        upgrader = OpenWrt(upgrade_op, device_conn)
-        upgrade_command = upgrader.get_upgrade_command('/tmp/test.bin')
-        self.assertEqual(upgrade_command, '/sbin/sysupgrade -v -c /tmp/test.bin')
+
+        with self.subTest('Test upgrade command without upgrade options'):
+            upgrade_op.upgrate_options = {}
+            upgrader = OpenWrt(upgrade_op, device_conn)
+            upgrade_command = upgrader.get_upgrade_command('/tmp/test.bin')
+            self.assertEqual(upgrade_command, '/sbin/sysupgrade -v -c /tmp/test.bin')
+
+        with self.subTest('Test upgrade command with upgrade options'):
+            upgrade_op.upgrade_options = {
+                'c': True,
+                'o': False,
+                'u': False,
+                'n': False,
+                'p': False,
+                'k': False,
+                'F': True,
+            }
+            upgrader = OpenWrt(upgrade_op, device_conn)
+            upgrade_command = upgrader.get_upgrade_command('/tmp/test.bin')
+            self.assertEqual(upgrade_command, '/sbin/sysupgrade -v -c -F /tmp/test.bin')
 
     @patch('scp.SCPClient.putfo')
     @patch.object(OpenWrt, 'RECONNECT_DELAY', 0)
diff --git a/openwisp_firmware_upgrader/tests/test_selenium.py b/openwisp_firmware_upgrader/tests/test_selenium.py
index b7eda986..7d8805e9 100644
--- a/openwisp_firmware_upgrader/tests/test_selenium.py
+++ b/openwisp_firmware_upgrader/tests/test_selenium.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
 import swapper
 from django.conf import settings
 from django.contrib.staticfiles.testing import StaticLiveServerTestCase
@@ -9,7 +11,7 @@
 from selenium.webdriver.common.by import By
 from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
 from selenium.webdriver.support import expected_conditions as EC
-from selenium.webdriver.support.ui import WebDriverWait
+from selenium.webdriver.support.ui import Select, WebDriverWait
 
 from openwisp_controller.tests.utils import SeleniumTestMixin
 from openwisp_firmware_upgrader.hardware import REVERSE_FIRMWARE_IMAGE_MAP
@@ -17,20 +19,39 @@
 from openwisp_utils.tests import capture_any_output
 
 from ..swapper import load_model
+from ..upgraders.openwisp import OpenWrt
 
 Device = swapper.load_model('config', 'Device')
 DeviceConnection = swapper.load_model('connection', 'DeviceConnection')
 UpgradeOperation = load_model('UpgradeOperation')
 DeviceFirmware = load_model('DeviceFirmware')
+BatchUpgradeOperation = load_model('BatchUpgradeOperation')
 
 
 class TestDeviceAdmin(TestUpgraderMixin, SeleniumTestMixin, StaticLiveServerTestCase):
     config_app_label = 'config'
+    firmware_app_label = 'firmware_upgrader'
     admin_username = 'admin'
     admin_password = 'password'
     os = 'OpenWrt 19.07-SNAPSHOT r11061-6ffd4d8a4d'
     image_type = REVERSE_FIRMWARE_IMAGE_MAP['YunCore XD3200']
 
+    def _set_up_env(self):
+        org = self._get_org()
+        category = self._get_category(organization=org)
+        build1 = self._create_build(category=category, version='0.1', os=self.os)
+        build2 = self._create_build(
+            category=category, version='0.2', os='OpenWrt 21.03'
+        )
+        image1 = self._create_firmware_image(build=build1, type=self.image_type)
+        image2 = self._create_firmware_image(build=build2, type=self.image_type)
+        self._create_credentials(auto_add=True, organization=org)
+        device = self._create_device(
+            os=self.os, model=image2.boards[0], organization=org
+        )
+        self._create_config(device=device)
+        return org, category, build1, build2, image1, image2, device
+
     @classmethod
     def setUpClass(cls):
         super().setUpClass()
@@ -124,3 +145,244 @@ def test_restoring_deleted_device(self):
         self.assertEqual(Device.objects.count(), 1)
         self.assertEqual(DeviceConnection.objects.count(), 1)
         self.assertEqual(DeviceFirmware.objects.count(), 1)
+
+    @capture_any_output()
+    @patch(
+        'openwisp_firmware_upgrader.upgraders.openwrt.OpenWrt.upgrade',
+        return_value=True,
+    )
+    @patch(
+        'openwisp_controller.connection.models.DeviceConnection.connect',
+        return_value=True,
+    )
+    def test_device_firmware_upgrade_options(self, *args):
+        def save_device():
+            self.web_driver.find_element_by_xpath(
+                '//*[@id="device_form"]/div/div[1]/input[3]'
+            ).click()
+
+        _, _, _, _, _, image, device = self._set_up_env()
+        self.login()
+        self.open(
+            '{}#devicefirmware-group'.format(
+                reverse(
+                    f'admin:{self.config_app_label}_device_change', args=[device.id]
+                )
+            )
+        )
+        # JSONSchema Editor should not be rendered without a change in the image field
+        WebDriverWait(self.web_driver, 1).until(
+            EC.invisibility_of_element_located(
+                (By.CSS_SELECTOR, '#devicefirmware-group .jsoneditor-wrapper')
+            )
+        )
+        image_select = Select(
+            self.web_driver.find_element_by_id('id_devicefirmware-0-image')
+        )
+        image_select.select_by_value(str(image.pk))
+        # JSONSchema configuration editor should not be rendered
+        WebDriverWait(self.web_driver, 1).until(
+            EC.invisibility_of_element_located(
+                (
+                    By.XPATH,
+                    '//*[@id="id_devicefirmware-0-upgrade_options_jsoneditor"]/div/h3/span[4]/input',
+                )
+            )
+        )
+        # Select "None" image should hide JSONSchema Editor
+        image_select.select_by_value('')
+        WebDriverWait(self.web_driver, 1).until(
+            EC.invisibility_of_element_located(
+                (By.CSS_SELECTOR, '#id_devicefirmware-0-upgrade_options_jsoneditor')
+            )
+        )
+
+        # Select "build2" image
+        image_select.select_by_value(str(image.pk))
+        # Enable '-c' option
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="id_devicefirmware-0-upgrade_options_jsoneditor"]'
+            '/div/div[2]/div/div/div[1]/div/div[1]/label/input'
+        ).click()
+        # Enable '-F' option
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="id_devicefirmware-0-upgrade_options_jsoneditor"]'
+            '/div/div[2]/div/div/div[7]/div/div[1]/label/input'
+        ).click()
+        save_device()
+
+        # Delete DeviceFirmware
+        self.web_driver.find_element_by_id('id_devicefirmware-0-DELETE').click()
+        save_device()
+
+        # When adding firmware to the device for the first time,
+        # JSONSchema editor should be rendered only when the image
+        # is selected
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="devicefirmware-group"]/fieldset/div[2]/a'
+        ).click()
+        # JSONSchema Editor should not be rendered without a change in the image field
+        WebDriverWait(self.web_driver, 1).until(
+            EC.invisibility_of_element_located(
+                (By.CSS_SELECTOR, '#devicefirmware-group .jsoneditor-wrapper')
+            )
+        )
+        image_select = Select(
+            self.web_driver.find_element_by_id('id_devicefirmware-0-image')
+        )
+        image_select.select_by_index(1)
+        try:
+            WebDriverWait(self.web_driver, 1).until(
+                EC.visibility_of_element_located(
+                    (By.CSS_SELECTOR, '#devicefirmware-group .jsoneditor-wrapper')
+                )
+            )
+        except TimeoutError:
+            self.fail('JSONSchema editor not shown after changing firmware image')
+        save_device()
+
+    @capture_any_output()
+    @patch(
+        'openwisp_firmware_upgrader.upgraders.openwrt.OpenWrt.upgrade',
+        return_value=True,
+    )
+    @patch(
+        'openwisp_controller.connection.models.DeviceConnection.connect',
+        return_value=True,
+    )
+    def test_batch_upgrade_upgrade_options(self, *args):
+        _, _, _, build2, _, _, _ = self._set_up_env()
+        self.login()
+        self.open(
+            reverse(f'admin:{self.firmware_app_label}_build_change', args=[build2.id])
+        )
+        # Launch mass upgrade operation
+        self.web_driver.find_element_by_css_selector(
+            '.title-wrapper .object-tools form button[type="submit"]'
+        ).click()
+
+        # Ensure JSONSchema form is rendered
+        try:
+            WebDriverWait(self.web_driver, 1).until(
+                EC.visibility_of_element_located(
+                    (By.CSS_SELECTOR, '.jsoneditor-wrapper')
+                )
+            )
+        except TimeoutError:
+            self.fail('JSONSchema editor not shown after changing firmware image')
+        # JSONSchema configuration editor should not be rendered
+        WebDriverWait(self.web_driver, 1).until(
+            EC.invisibility_of_element_located(
+                (
+                    By.XPATH,
+                    '//*[@id="id_devicefirmware-0-upgrade_options_jsoneditor"]/div/h3/span[4]/input',
+                )
+            )
+        )
+        # Disable -c flag
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="id_upgrade_options_jsoneditor"]/div/div[2]/div/div/div[1]/div/div[1]/label/input'
+        ).click()
+        # Enable -n flag
+        self.web_driver.find_element_by_xpath(
+            '//*[@id="id_upgrade_options_jsoneditor"]/div/div[2]/div/div/div[3]/div/div[1]/label/input'
+        ).click()
+        # Upgrade all devices
+        self.web_driver.find_element_by_css_selector(
+            'input[name="upgrade_all"]'
+        ).click()
+        self.assertEqual(
+            BatchUpgradeOperation.objects.filter(
+                upgrade_options={
+                    'c': False,
+                    'o': False,
+                    'n': True,
+                    'u': False,
+                    'p': False,
+                    'k': False,
+                    'F': False,
+                }
+            ).count(),
+            1,
+        )
+        self.assertEqual(
+            UpgradeOperation.objects.filter(
+                upgrade_options={
+                    'c': False,
+                    'o': False,
+                    'n': True,
+                    'u': False,
+                    'p': False,
+                    'k': False,
+                    'F': False,
+                }
+            ).count(),
+            1,
+        )
+
+    @capture_any_output()
+    @patch(
+        'openwisp_firmware_upgrader.upgraders.openwrt.OpenWrt.upgrade',
+        return_value=True,
+    )
+    @patch(
+        'openwisp_controller.connection.models.DeviceConnection.connect',
+        return_value=True,
+    )
+    @patch.object(OpenWrt, 'SCHEMA', None)
+    def test_upgrader_with_unsupported_upgrade_options(self, *args):
+        org, category, build1, build2, image1, image2, device = self._set_up_env()
+        self.login()
+
+        with self.subTest('Test DeviceFirmware'):
+            self.open(
+                '{}#devicefirmware-group'.format(
+                    reverse(
+                        f'admin:{self.config_app_label}_device_change', args=[device.id]
+                    )
+                )
+            )
+            image_select = Select(
+                self.web_driver.find_element_by_id('id_devicefirmware-0-image')
+            )
+            image_select.select_by_value(str(image2.pk))
+            # Ensure JSONSchema editor is not rendered because
+            # the upgrader does not define a schema
+            WebDriverWait(self.web_driver, 1).until(
+                EC.invisibility_of_element_located(
+                    (By.CSS_SELECTOR, '#devicefirmware-group .jsoneditor-wrapper')
+                )
+            )
+            self.web_driver.find_element_by_xpath(
+                '//*[@id="device_form"]/div/div[1]/input[3]'
+            ).click()
+            self.assertEqual(
+                UpgradeOperation.objects.filter(upgrade_options={}).count(), 1
+            )
+        DeviceFirmware.objects.all().delete()
+        UpgradeOperation.objects.all().delete()
+
+        with self.subTest('Test BatchUpgradeOperation'):
+            self.open(
+                reverse(
+                    f'admin:{self.firmware_app_label}_build_change', args=[build2.id]
+                )
+            )
+            # Launch mass upgrade operation
+            self.web_driver.find_element_by_css_selector(
+                '.title-wrapper .object-tools form button[type="submit"]'
+            ).click()
+            # Ensure JSONSchema editor is not rendered because
+            # the upgrader does not define a schema
+            WebDriverWait(self.web_driver, 1).until(
+                EC.invisibility_of_element_located(
+                    (By.CSS_SELECTOR, '#devicefirmware-group .jsoneditor-wrapper')
+                )
+            )
+            # Upgrade all devices
+            self.web_driver.find_element_by_css_selector(
+                'input[name="upgrade_all"]'
+            ).click()
+            self.assertEqual(
+                UpgradeOperation.objects.filter(upgrade_options={}).count(), 1
+            )
diff --git a/openwisp_firmware_upgrader/tests/test_utils.py b/openwisp_firmware_upgrader/tests/test_utils.py
new file mode 100644
index 00000000..598b517d
--- /dev/null
+++ b/openwisp_firmware_upgrader/tests/test_utils.py
@@ -0,0 +1,33 @@
+from unittest.mock import patch
+
+from django.test import TestCase
+
+from .. import settings as app_settings
+from ..utils import get_upgrader_class_from_device_connection
+from .base import TestUpgraderMixin
+
+
+class TestUtils(TestUpgraderMixin, TestCase):
+    @patch('logging.Logger.exception')
+    def test_get_upgrader_class_from_device_connection(self, mocked_logger):
+        device_conn = self._create_device_connection()
+
+        with self.subTest('Test upgrader is not configured in "UPGRADERS_MAP"'):
+            with patch.object(app_settings, 'UPGRADERS_MAP', {}):
+                upgrader_class = get_upgrader_class_from_device_connection(device_conn)
+                self.assertEqual(upgrader_class, None)
+                mocked_logger.assert_called()
+
+        mocked_logger.reset_mock()
+
+        with self.subTest('Test upgrader is not configured in "UPGRADERS_MAP"'):
+            with patch.object(
+                app_settings,
+                'UPGRADERS_MAP',
+                {
+                    device_conn.update_strategy: 'openwisp_firmware_upgrader.upgraders.invalid'
+                },
+            ):
+                upgrader_class = get_upgrader_class_from_device_connection(device_conn)
+                self.assertEqual(upgrader_class, None)
+                mocked_logger.assert_called()
diff --git a/tests/openwisp2/sample_firmware_upgrader/admin.py b/tests/openwisp2/sample_firmware_upgrader/admin.py
index e78e0997..528cf1e9 100644
--- a/tests/openwisp2/sample_firmware_upgrader/admin.py
+++ b/tests/openwisp2/sample_firmware_upgrader/admin.py
@@ -2,8 +2,10 @@
     BatchUpgradeOperationAdmin,
     BuildAdmin,
     CategoryAdmin,
+    DeviceUpgradeOperationInline,
     UpgradeOperationForm,
 )
 
 BatchUpgradeOperationAdmin.fields.append('details')
 UpgradeOperationForm.Meta.fields.append('details')
+DeviceUpgradeOperationInline.fields.append('details')
diff --git a/tests/openwisp2/sample_firmware_upgrader/migrations/0001_initial.py b/tests/openwisp2/sample_firmware_upgrader/migrations/0001_initial.py
index 229781ac..77c20056 100644
--- a/tests/openwisp2/sample_firmware_upgrader/migrations/0001_initial.py
+++ b/tests/openwisp2/sample_firmware_upgrader/migrations/0001_initial.py
@@ -68,6 +68,7 @@ class Migration(migrations.Migration):
                     ),
                 ),
                 ('details', models.CharField(blank=True, max_length=64, null=True)),
+                ('upgrade_options', models.JSONField(default=dict, blank=True)),
             ],
             options={
                 'verbose_name': 'Mass upgrade operation',
@@ -263,6 +264,7 @@ class Migration(migrations.Migration):
                         to=swapper.get_model_name('firmware_upgrader', 'FirmwareImage'),
                     ),
                 ),
+                ('upgrade_options', models.JSONField(default=dict, blank=True)),
             ],
             options={
                 'abstract': False,