From 58df04b4afe1c7bdfc9139013377b569740fccb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laura=20Kl=C3=BCnder?= Date: Fri, 10 Nov 2023 16:08:55 +0100 Subject: [PATCH] OTA create view --- .../0010_otaupdate_otaupdaterecipient.py | 69 +++++++++++++ src/c3nav/mesh/models.py | 83 +++++++++++++++- .../templates/mesh/firmware_build_detail.html | 49 +--------- .../mesh/templates/mesh/firmware_detail.html | 57 +---------- .../mesh/fragment_firmware_cell.html | 12 +++ .../templates/mesh/fragment_ota_form.html | 74 ++++++++++++++ src/c3nav/mesh/templates/mesh/nodes.html | 10 +- src/c3nav/mesh/views/firmware.py | 83 ++++++---------- src/c3nav/site/forms.py | 97 ++++++++++++++++++- src/c3nav/site/static/site/css/c3nav.scss | 3 + 10 files changed, 368 insertions(+), 169 deletions(-) create mode 100644 src/c3nav/mesh/migrations/0010_otaupdate_otaupdaterecipient.py create mode 100644 src/c3nav/mesh/templates/mesh/fragment_firmware_cell.html create mode 100644 src/c3nav/mesh/templates/mesh/fragment_ota_form.html diff --git a/src/c3nav/mesh/migrations/0010_otaupdate_otaupdaterecipient.py b/src/c3nav/mesh/migrations/0010_otaupdate_otaupdaterecipient.py new file mode 100644 index 00000000..60cffae5 --- /dev/null +++ b/src/c3nav/mesh/migrations/0010_otaupdate_otaupdaterecipient.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.1 on 2023-11-10 14:38 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + dependencies = [ + ("mesh", "0009_meshuplink"), + ] + + operations = [ + migrations.CreateModel( + name="OTAUpdate", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "created", + models.DateTimeField(auto_now_add=True, verbose_name="creation"), + ), + ( + "build", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="mesh.firmwarebuild", + ), + ), + ], + ), + migrations.CreateModel( + name="OTAUpdateRecipient", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "node", + models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + related_name="ota_updates", + to="mesh.meshnode", + verbose_name="node", + ), + ), + ( + "update", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="recipients", + to="mesh.otaupdate", + ), + ), + ], + ), + ] diff --git a/src/c3nav/mesh/models.py b/src/c3nav/mesh/models.py index a6c1d3d0..fa4657c8 100644 --- a/src/c3nav/mesh/models.py +++ b/src/c3nav/mesh/models.py @@ -41,17 +41,26 @@ class FirmwareDescription: ) +@dataclass(frozen=True) +class HardwareDescription: + chip: ChipType + board: BoardType + + class MeshNodeQuerySet(models.QuerySet): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self._prefetch_last_messages = set() self._prefetch_last_messages_done = False self._prefetch_firmwares = False + self._prefetch_ota = False + self._prefetch_ota_done = False def _clone(self): clone = super()._clone() clone._prefetch_last_messages = self._prefetch_last_messages clone._prefetch_firmwares = self._prefetch_firmwares + clone._prefetch_ota = self._prefetch_ota return clone def prefetch_last_messages(self, *types: MeshMessageType): @@ -67,8 +76,14 @@ class MeshNodeQuerySet(models.QuerySet): clone._prefetch_firmwares = True return clone + def prefetch_ota(self): + clone = self._chain() + clone._prefetch_pta = True + return clone + def _fetch_all(self): super()._fetch_all() + nodes = None if self._prefetch_last_messages and not self._prefetch_last_messages_done: nodes: dict[str, MeshNode] = {node.pk: node for node in self._result_cache} try: @@ -125,6 +140,22 @@ class MeshNodeQuerySet(models.QuerySet): for node in nodes_to_complete: node.firmware_desc.created = created_lookup[node.firmware_desc.sha256_hash] + if self._prefetch_ota and not self._prefetch_ota_done: + if nodes is None: + nodes: dict[str, MeshNode] = {node.pk: node for node in self._result_cache} + try: + for ota in OTAUpdateRecipient.objects.order_by('node', '-update__created').filter( + src_node__in=nodes.keys(), + ).select_related("update", "update__build").distinct('node'): + # noinspection PyUnresolvedReferences + nodes[ota.node_id]._current_ota = ota + for node in nodes.values(): + if not hasattr(node, "_current_ota"): + node._current_ota = None + self._prefetch_ota_done = True + except NotSupportedError: + pass + class LastMessagesByTypeLookup(UserDict): def __init__(self, node): @@ -183,8 +214,18 @@ class MeshNode(models.Model): def last_messages(self) -> Mapping[Any, "NodeMessage"]: return LastMessagesByTypeLookup(self) + @cached_property + def current_ota(self) -> Optional["OTAUpdateRecipient"]: + try: + # noinspection PyUnresolvedReferences + return self._current_ota + except AttributeError: + return self.ota_updates.order_by('-update__created').first() + def get_firmware_description(self) -> FirmwareDescription: + # noinspection PyTypeChecker firmware_msg: ConfigFirmwareMessage = self.last_messages[MeshMessageType.CONFIG_FIRMWARE].parsed + # noinspection PyTypeChecker hardware_msg: ConfigHardwareMessage = self.last_messages[MeshMessageType.CONFIG_HARDWARE].parsed return FirmwareDescription( chip=hardware_msg.chip, @@ -194,6 +235,13 @@ class MeshNode(models.Model): sha256_hash=firmware_msg.app_desc.app_elf_sha256, ) + def get_hardware_description(self) -> HardwareDescription: + # noinspection PyUnresolvedReferences + return HardwareDescription( + chip=self.last_messages[MeshMessageType.CONFIG_HARDWARE].parsed.chip, + board=self.last_messages[MeshMessageType.CONFIG_BOARD].parsed.board_config.board, + ) + # overriden by prefetch_firmwares() firmware_desc = None @@ -203,6 +251,7 @@ class MeshNode(models.Model): @cached_property def board(self) -> ChipType: + # noinspection PyUnresolvedReferences return self.last_messages[MeshMessageType.CONFIG_BOARD].parsed.board_config.board def get_uplink(self) -> Optional["MeshUplink"]: @@ -233,7 +282,7 @@ class MeshUplink(models.Model): name = models.CharField(_('channel name'), max_length=128) started = models.DateTimeField(_('started'), auto_now_add=True) - node = models.ForeignKey('MeshNode', models.PROTECT, related_name='uplink_sessions', + node = models.ForeignKey(MeshNode, models.PROTECT, related_name='uplink_sessions', verbose_name=_('node')) last_ping = models.DateTimeField(_('last ping from consumer')) end_reason = models.CharField(_('end reason'), choices=EndReason.choices, null=True, max_length=16) @@ -246,9 +295,9 @@ class MeshUplink(models.Model): class NodeMessage(models.Model): MESSAGE_TYPES = [(msgtype.name, msgtype.pretty_name) for msgtype in MeshMessageType] - src_node = models.ForeignKey('MeshNode', models.PROTECT, - related_name='received_messages', verbose_name=_('node')) - uplink = models.ForeignKey('MeshUplink', models.PROTECT, related_name='relayed_messages', + src_node = models.ForeignKey(MeshNode, models.PROTECT, related_name='received_messages', + verbose_name=_('node')) + uplink = models.ForeignKey(MeshUplink, models.PROTECT, related_name='relayed_messages', verbose_name=_('uplink')) datetime = models.DateTimeField(_('datetime'), db_index=True, auto_now_add=True) message_type = models.CharField(_('message type'), max_length=24, db_index=True, choices=MESSAGE_TYPES) @@ -308,6 +357,10 @@ class FirmwareBuild(models.Model): def boards(self): return {BoardType[board.board] for board in self.firmwarebuildboard_set.all()} + @property + def chip_type(self) -> ChipType: + return ChipType(self.chip) + def serialize(self): return { 'chip': ChipType(self.chip).name, @@ -318,7 +371,7 @@ class FirmwareBuild(models.Model): def get_firmware_description(self) -> FirmwareDescription: return FirmwareDescription( - chip=ChipType(self.chip), + chip=self.chip_type, project_name=self.version.project_name, version=self.version.version, idf_version=self.version.idf_version, @@ -327,6 +380,15 @@ class FirmwareBuild(models.Model): build=self, ) + def get_hardware_descriptions(self) -> list[HardwareDescription]: + return [ + HardwareDescription( + chip=self.chip_type, + board=board, + ) + for board in self.boards + ] + class FirmwareBuildBoard(models.Model): BOARDS = [(boardtype.name, boardtype.pretty_name) for boardtype in BoardType] @@ -337,3 +399,14 @@ class FirmwareBuildBoard(models.Model): unique_together = [ ('build', 'board'), ] + + +class OTAUpdate(models.Model): + build = models.ForeignKey(FirmwareBuild, on_delete=models.CASCADE) + created = models.DateTimeField(_('creation'), auto_now_add=True) + + +class OTAUpdateRecipient(models.Model): + update = models.ForeignKey(OTAUpdate, on_delete=models.CASCADE, related_name='recipients') + node = models.ForeignKey(MeshNode, models.PROTECT, related_name='ota_updates', + verbose_name=_('node')) diff --git a/src/c3nav/mesh/templates/mesh/firmware_build_detail.html b/src/c3nav/mesh/templates/mesh/firmware_build_detail.html index 8f7a5ebe..c611ca48 100644 --- a/src/c3nav/mesh/templates/mesh/firmware_build_detail.html +++ b/src/c3nav/mesh/templates/mesh/firmware_build_detail.html @@ -10,7 +10,7 @@

Project name: {{ build.version.project_name }}
- Version: {{ build.version.version }}
+ Version: {{ build.version.version }}
IDF Version: {{ build.version.idf_version }}
Uploader: {{ build.version.uploader }}
Created: {{ build.version.created }}
@@ -27,52 +27,7 @@

-

Installed nodes

- - - - - - {% for node in installed_nodes %} - - - - - {% endfor %} -
{% trans 'Node' %}{% trans 'Hardware' %}
- {% mesh_node node %} - - {{ node.board.pretty_name }} ({{ node.chip.pretty_name }}) -
- -

Compatible nodes

- - - - - - - {% for node in compatible_nodes %} - - - - - - {% endfor %} -
{% trans 'Node' %}{% trans 'Hardware' %}{% trans 'Current Firmware' %}
- {% mesh_node node %} - - {{ node.board.pretty_name }} ({{ node.chip.pretty_name }}) - - {% if node.firmware_desc.build %} - - {{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }} -
- Build {{ node.firmware_desc.build.variant }} - {% else %} - {{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }} - {% endif %} -
+ {% include "mesh/fragment_ota_form.html" %}
{% endblock %} diff --git a/src/c3nav/mesh/templates/mesh/firmware_detail.html b/src/c3nav/mesh/templates/mesh/firmware_detail.html index 1f0551ba..7fd4d6ea 100644 --- a/src/c3nav/mesh/templates/mesh/firmware_detail.html +++ b/src/c3nav/mesh/templates/mesh/firmware_detail.html @@ -42,62 +42,7 @@
-

Installed nodes

- - - - - - - {% for node in installed_nodes %} - - - - - - {% endfor %} -
{% trans 'Node' %}{% trans 'Hardware' %}{% trans 'Build' %}
- {% mesh_node node %} - - {{ node.board.pretty_name }} ({{ node.chip.pretty_name }}) - - {{ node.firmware_desc.build.variant }} -
- -

Compatible nodes

- - - - - - - - {% for node in compatible_nodes %} - - - - - - - {% endfor %} -
{% trans 'Node' %}{% trans 'Hardware' %}{% trans 'Current Firmware' %}{% trans 'Compatible Builds' %}
- {% mesh_node node %} - - {{ node.board.pretty_name }} ({{ node.chip.pretty_name }}) - - {% if node.firmware_desc.build %} - - {{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }} -
- Build {{ node.firmware_desc.build.variant }} - {% else %} - {{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }} - {% endif %} -
- {% for build in node.compatible_builds %} - {{ build.variant }}
- {% endfor %} -
+ {% include "mesh/fragment_ota_form.html" %}
{% endblock %} diff --git a/src/c3nav/mesh/templates/mesh/fragment_firmware_cell.html b/src/c3nav/mesh/templates/mesh/fragment_firmware_cell.html new file mode 100644 index 00000000..69026868 --- /dev/null +++ b/src/c3nav/mesh/templates/mesh/fragment_firmware_cell.html @@ -0,0 +1,12 @@ +{% if firmware_desc.build %} + + {{ firmware_desc.project_name }} + {{ firmware_desc.version }} +
+ Variant: + {{ firmware_desc.build.variant }} + +{% else %} + {{ firmware_desc.project_name }} {{ firmware_desc.version }}
+ {{ firmware_desc.sha256_hash }} +{% endif %} diff --git a/src/c3nav/mesh/templates/mesh/fragment_ota_form.html b/src/c3nav/mesh/templates/mesh/fragment_ota_form.html new file mode 100644 index 00000000..a9fbddc5 --- /dev/null +++ b/src/c3nav/mesh/templates/mesh/fragment_ota_form.html @@ -0,0 +1,74 @@ +{% load i18n mesh_node %} +
+ {% csrf_token %} + {% for group in form.groups %} +

{{ group.hardware.board.pretty_name }} ({{ group.hardware.chip.pretty_name }})

+
+ Set + + + to + {% if group.builds|length == 1 %} + + {% else %} + + {% endif %} + + +
+ + + + + + + + {% for node, field in group.fields.values %} + + + + + + + {% endfor %} +
{% trans 'Node' %}{% trans 'Current Firmware' %}{% trans 'Current OTA' %} + {% if group.builds|length == 1 %} + Install {{ group.builds.0.variant }} + {% else %} + {% trans 'Build to install' %} + {% endif %} +
+ {% mesh_node node %} + + {% include "mesh/fragment_firmware_cell.html" with firmware_desc=node.firmware_desc %} + + {% if node.current_ota %} + #{{ node.current_ota.update.pk }} ({{ node.current_ota.update.created }})
+ {% include "mesh/fragment_firmware_cell.html" with firmware_desc=node.current_ota.update.build.get_firmware_description %} + {% else %} + - + {% endif %} +
+ {{ field }} +
+ {% endfor %} +
+ +
+
diff --git a/src/c3nav/mesh/templates/mesh/nodes.html b/src/c3nav/mesh/templates/mesh/nodes.html index 863fa300..bad810c6 100644 --- a/src/c3nav/mesh/templates/mesh/nodes.html +++ b/src/c3nav/mesh/templates/mesh/nodes.html @@ -21,15 +21,7 @@ ({{ node.last_messages.CONFIG_HARDWARE.parsed.chip.pretty_name }} rev{{ node.last_messages.CONFIG_HARDWARE.parsed.revision_major }}.{{ node.last_messages.CONFIG_HARDWARE.parsed.revision_minor }}) - {% if node.firmware_desc.build %} - {{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}
- - {{ node.firmware_desc.build.variant }} - - {% else %} - {{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}
- {{ node.firmware_desc.sha256_hash }} - {% endif %} + {% include "mesh/fragment_firmware_cell.html" with firmware_desc=node.firmware_desc %} {% blocktrans trimmed with timesince=node.last_messages.any.datetime|timesince %} diff --git a/src/c3nav/mesh/views/firmware.py b/src/c3nav/mesh/views/firmware.py index c2d7367d..7ffa77d0 100644 --- a/src/c3nav/mesh/views/firmware.py +++ b/src/c3nav/mesh/views/firmware.py @@ -1,8 +1,10 @@ +from django.contrib.messages.views import SuccessMessageMixin from django.views.generic import DetailView, ListView, TemplateView +from django.views.generic.edit import FormMixin -from c3nav.mesh.messages import MeshMessageType from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, MeshNode from c3nav.mesh.views.base import MeshControlMixin +from c3nav.site.forms import OTACreateForm class FirmwaresListView(MeshControlMixin, ListView): @@ -33,7 +35,22 @@ class FirmwaresCurrentListView(MeshControlMixin, TemplateView): } -class FirmwareDetailView(MeshControlMixin, DetailView): +class OTACreateMixin(SuccessMessageMixin, FormMixin): + form_class = OTACreateForm + success_message = 'OTA have been created' + + def post(self, *args, **kwargs): + form = self.get_form() + if not form.is_valid(): + return self.form_invalid(form) + form.save() + return self.form_valid(form) + + def get_success_url(self): + return self.request.path + + +class FirmwareDetailView(OTACreateMixin, MeshControlMixin, DetailView): model = FirmwareVersion template_name = "mesh/firmware_detail.html" context_object_name = "firmware" @@ -41,38 +58,21 @@ class FirmwareDetailView(MeshControlMixin, DetailView): def get_queryset(self): return super().get_queryset().prefetch_related('builds', 'builds__firmwarebuildboard_set') + def get_form_kwargs(self): + return { + **super().get_form_kwargs(), + 'builds': self.get_object().builds.all(), + } + def get_context_data(self, **kwargs): ctx = super().get_context_data() - - nodes: list[MeshNode] = list(MeshNode.objects.all().prefetch_firmwares().prefetch_last_messages( - MeshMessageType.CONFIG_BOARD, - )) - builds = self.get_object().builds.all() - - build_lookups = set(build.get_firmware_description().get_lookup() for build in builds) - - installed_nodes = [] - compatible_nodes = [] - for node in nodes: - if node.firmware_desc.get_lookup() in build_lookups: - installed_nodes.append(node) - else: - node.compatible_builds = [] - for build in builds: - if node.board in build.boards: - node.compatible_builds.append(build) - if node.compatible_builds: - compatible_nodes.append(node) - ctx.update({ - 'builds': builds, - 'installed_nodes': installed_nodes, - 'compatible_nodes': compatible_nodes, + 'builds': self.get_object().builds.all(), }) return ctx -class FirmwareBuildDetailView(MeshControlMixin, DetailView): +class FirmwareBuildDetailView(OTACreateMixin, MeshControlMixin, DetailView): model = FirmwareBuild template_name = "mesh/firmware_build_detail.html" context_object_name = "build" @@ -80,27 +80,8 @@ class FirmwareBuildDetailView(MeshControlMixin, DetailView): def get_queryset(self): return super().get_queryset().prefetch_related('firmwarebuildboard_set') - def get_context_data(self, **kwargs): - ctx = super().get_context_data() - - nodes = list(MeshNode.objects.all().prefetch_firmwares().prefetch_last_messages( - MeshMessageType.CONFIG_BOARD, - )) - - build_lookup = self.get_object().get_firmware_description().get_lookup() - build_boards = self.get_object().boards - - installed_nodes = [] - compatible_nodes = [] - for node in nodes: - if node.firmware_desc.get_lookup() == build_lookup: - installed_nodes.append(node) - else: - if node.board in build_boards: - compatible_nodes.append(node) - - ctx.update({ - 'installed_nodes': installed_nodes, - 'compatible_nodes': compatible_nodes, - }) - return ctx + def get_form_kwargs(self): + return { + **super().get_form_kwargs(), + 'builds': [self.get_object()], + } diff --git a/src/c3nav/site/forms.py b/src/c3nav/site/forms.py index 62a91ff7..fb40a56c 100644 --- a/src/c3nav/site/forms.py +++ b/src/c3nav/site/forms.py @@ -1,12 +1,18 @@ +from dataclasses import dataclass +from dataclasses import replace as dataclass_replace +from functools import cached_property from operator import attrgetter +from typing import Any, Sequence from django.db import transaction -from django.forms import Form, ModelChoiceField, ModelForm +from django.forms import BooleanField, ChoiceField, Form, ModelChoiceField, ModelForm from django.utils.translation import gettext_lazy as _ from c3nav.mapdata.forms import I18nModelFormMixin from c3nav.mapdata.models.locations import Position from c3nav.mapdata.models.report import Report, ReportUpdate +from c3nav.mesh.messages import MeshMessageType +from c3nav.mesh.models import FirmwareBuild, HardwareDescription, MeshNode, OTAUpdate class ReportIssueForm(I18nModelFormMixin, ModelForm): @@ -65,3 +71,92 @@ class PositionSetForm(Form): super().__init__(*args, **kwargs) self.fields['position'].queryset = Position.objects.filter(owner=request.user) self.fields['position'].label_from_instance = attrgetter('name') + + +@dataclass +class OTAFormGroup: + hardware: HardwareDescription + builds: Sequence[FirmwareBuild] + fields: dict[str, tuple[MeshNode, Any]] + + @cached_property + def builds_by_id(self) -> dict[int, FirmwareBuild]: + return {build.pk: build for build in self.builds} + + +class OTACreateForm(Form): + def __init__(self, builds: Sequence[FirmwareBuild], *args, **kwargs): + super().__init__(*args, **kwargs) + + nodes: Sequence[MeshNode] = MeshNode.objects.prefetch_last_messages( + MeshMessageType.CONFIG_BOARD + ).prefetch_firmwares().prefetch_ota() + + builds_by_hardware = {} + for build in builds: + for hardware_desc in build.get_hardware_descriptions(): + builds_by_hardware.setdefault(hardware_desc, []).append(build) + + nodes_by_hardware = {} + for node in nodes: + nodes_by_hardware.setdefault(node.get_hardware_description(), []).append(node) + + self._groups: list[OTAFormGroup] = [] + for hardware, hw_nodes in sorted(nodes_by_hardware.items(), key=lambda k: len(k[1]), reverse=True): + try: + hw_builds = builds_by_hardware[hardware] + except KeyError: + continue + choices = [ + ('', '---'), + *((build.pk, build.variant) for build in hw_builds) + ] + + group = OTAFormGroup( + hardware=hardware, + builds=hw_builds, + fields={ + f'build_{node.pk}': (node, ( + ChoiceField(choices=choices, required=False) + if len(hw_builds) > 1 + else BooleanField(required=False) + )) for node in hw_nodes + } + ) + for name, (node, hw_field) in group.fields.items(): + self.fields[name] = hw_field + self._groups.append(group) + + @property + def groups(self) -> list[OTAFormGroup]: + return [ + dataclass_replace(group, fields={ + name: (node, self[name]) + for name, (node, hw_field) in group.fields.items() + }) + for group in self._groups + ] + + @property + def selected_builds(self): + build_nodes = {} + for group in self._groups: + for name, (node, hw_field) in group.fields.items(): + value = self.cleaned_data.get(name, None) + if not value: + continue + if len(group.builds) == 1: + build_nodes.setdefault(group.builds[0], []).append(node) + else: + build_nodes.setdefault(group.builds[0], []).append(group.builds_by_id[int(value)]) + return build_nodes + + def save(self) -> list[OTAUpdate]: + updates = [] + with transaction.atomic(): + for build, nodes in self.selected_builds.items(): + update = OTAUpdate.objects.create(build=build) + for node in nodes: + update.recipients.create(node=node) + updates.append(update) + return updates diff --git a/src/c3nav/site/static/site/css/c3nav.scss b/src/c3nav/site/static/site/css/c3nav.scss index 3a24fb79..5f4e88e4 100644 --- a/src/c3nav/site/static/site/css/c3nav.scss +++ b/src/c3nav/site/static/site/css/c3nav.scss @@ -1365,3 +1365,6 @@ button + button { padding: 2px; } } +table td select:last-child { + margin-bottom: 0; +}