OTA create view

This commit is contained in:
Laura Klünder 2023-11-10 16:08:55 +01:00
parent 73876f1b75
commit 58df04b4af
10 changed files with 368 additions and 169 deletions

View file

@ -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",
),
),
],
),
]

View file

@ -41,17 +41,26 @@ class FirmwareDescription:
) )
@dataclass(frozen=True)
class HardwareDescription:
chip: ChipType
board: BoardType
class MeshNodeQuerySet(models.QuerySet): class MeshNodeQuerySet(models.QuerySet):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self._prefetch_last_messages = set() self._prefetch_last_messages = set()
self._prefetch_last_messages_done = False self._prefetch_last_messages_done = False
self._prefetch_firmwares = False self._prefetch_firmwares = False
self._prefetch_ota = False
self._prefetch_ota_done = False
def _clone(self): def _clone(self):
clone = super()._clone() clone = super()._clone()
clone._prefetch_last_messages = self._prefetch_last_messages clone._prefetch_last_messages = self._prefetch_last_messages
clone._prefetch_firmwares = self._prefetch_firmwares clone._prefetch_firmwares = self._prefetch_firmwares
clone._prefetch_ota = self._prefetch_ota
return clone return clone
def prefetch_last_messages(self, *types: MeshMessageType): def prefetch_last_messages(self, *types: MeshMessageType):
@ -67,8 +76,14 @@ class MeshNodeQuerySet(models.QuerySet):
clone._prefetch_firmwares = True clone._prefetch_firmwares = True
return clone return clone
def prefetch_ota(self):
clone = self._chain()
clone._prefetch_pta = True
return clone
def _fetch_all(self): def _fetch_all(self):
super()._fetch_all() super()._fetch_all()
nodes = None
if self._prefetch_last_messages and not self._prefetch_last_messages_done: 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} nodes: dict[str, MeshNode] = {node.pk: node for node in self._result_cache}
try: try:
@ -125,6 +140,22 @@ class MeshNodeQuerySet(models.QuerySet):
for node in nodes_to_complete: for node in nodes_to_complete:
node.firmware_desc.created = created_lookup[node.firmware_desc.sha256_hash] 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): class LastMessagesByTypeLookup(UserDict):
def __init__(self, node): def __init__(self, node):
@ -183,8 +214,18 @@ class MeshNode(models.Model):
def last_messages(self) -> Mapping[Any, "NodeMessage"]: def last_messages(self) -> Mapping[Any, "NodeMessage"]:
return LastMessagesByTypeLookup(self) 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: def get_firmware_description(self) -> FirmwareDescription:
# noinspection PyTypeChecker
firmware_msg: ConfigFirmwareMessage = self.last_messages[MeshMessageType.CONFIG_FIRMWARE].parsed firmware_msg: ConfigFirmwareMessage = self.last_messages[MeshMessageType.CONFIG_FIRMWARE].parsed
# noinspection PyTypeChecker
hardware_msg: ConfigHardwareMessage = self.last_messages[MeshMessageType.CONFIG_HARDWARE].parsed hardware_msg: ConfigHardwareMessage = self.last_messages[MeshMessageType.CONFIG_HARDWARE].parsed
return FirmwareDescription( return FirmwareDescription(
chip=hardware_msg.chip, chip=hardware_msg.chip,
@ -194,6 +235,13 @@ class MeshNode(models.Model):
sha256_hash=firmware_msg.app_desc.app_elf_sha256, 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() # overriden by prefetch_firmwares()
firmware_desc = None firmware_desc = None
@ -203,6 +251,7 @@ class MeshNode(models.Model):
@cached_property @cached_property
def board(self) -> ChipType: def board(self) -> ChipType:
# noinspection PyUnresolvedReferences
return self.last_messages[MeshMessageType.CONFIG_BOARD].parsed.board_config.board return self.last_messages[MeshMessageType.CONFIG_BOARD].parsed.board_config.board
def get_uplink(self) -> Optional["MeshUplink"]: def get_uplink(self) -> Optional["MeshUplink"]:
@ -233,7 +282,7 @@ class MeshUplink(models.Model):
name = models.CharField(_('channel name'), max_length=128) name = models.CharField(_('channel name'), max_length=128)
started = models.DateTimeField(_('started'), auto_now_add=True) 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')) verbose_name=_('node'))
last_ping = models.DateTimeField(_('last ping from consumer')) last_ping = models.DateTimeField(_('last ping from consumer'))
end_reason = models.CharField(_('end reason'), choices=EndReason.choices, null=True, max_length=16) 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): class NodeMessage(models.Model):
MESSAGE_TYPES = [(msgtype.name, msgtype.pretty_name) for msgtype in MeshMessageType] MESSAGE_TYPES = [(msgtype.name, msgtype.pretty_name) for msgtype in MeshMessageType]
src_node = models.ForeignKey('MeshNode', models.PROTECT, src_node = models.ForeignKey(MeshNode, models.PROTECT, related_name='received_messages',
related_name='received_messages', verbose_name=_('node')) verbose_name=_('node'))
uplink = models.ForeignKey('MeshUplink', models.PROTECT, related_name='relayed_messages', uplink = models.ForeignKey(MeshUplink, models.PROTECT, related_name='relayed_messages',
verbose_name=_('uplink')) verbose_name=_('uplink'))
datetime = models.DateTimeField(_('datetime'), db_index=True, auto_now_add=True) 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) 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): def boards(self):
return {BoardType[board.board] for board in self.firmwarebuildboard_set.all()} return {BoardType[board.board] for board in self.firmwarebuildboard_set.all()}
@property
def chip_type(self) -> ChipType:
return ChipType(self.chip)
def serialize(self): def serialize(self):
return { return {
'chip': ChipType(self.chip).name, 'chip': ChipType(self.chip).name,
@ -318,7 +371,7 @@ class FirmwareBuild(models.Model):
def get_firmware_description(self) -> FirmwareDescription: def get_firmware_description(self) -> FirmwareDescription:
return FirmwareDescription( return FirmwareDescription(
chip=ChipType(self.chip), chip=self.chip_type,
project_name=self.version.project_name, project_name=self.version.project_name,
version=self.version.version, version=self.version.version,
idf_version=self.version.idf_version, idf_version=self.version.idf_version,
@ -327,6 +380,15 @@ class FirmwareBuild(models.Model):
build=self, 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): class FirmwareBuildBoard(models.Model):
BOARDS = [(boardtype.name, boardtype.pretty_name) for boardtype in BoardType] BOARDS = [(boardtype.name, boardtype.pretty_name) for boardtype in BoardType]
@ -337,3 +399,14 @@ class FirmwareBuildBoard(models.Model):
unique_together = [ unique_together = [
('build', 'board'), ('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'))

View file

@ -10,7 +10,7 @@
<div> <div>
<p> <p>
<strong>Project name:</strong> {{ build.version.project_name }}<br> <strong>Project name:</strong> {{ build.version.project_name }}<br>
<strong>Version:</strong> {{ build.version.version }}<br> <strong>Version:</strong> <a href="{% url 'mesh.firmwares.detail' pk=build.version.pk %}">{{ build.version.version }}</a><br>
<strong>IDF Version:</strong> {{ build.version.idf_version }}<br> <strong>IDF Version:</strong> {{ build.version.idf_version }}<br>
<strong>Uploader:</strong> {{ build.version.uploader }}<br> <strong>Uploader:</strong> {{ build.version.uploader }}<br>
<strong>Created:</strong> {{ build.version.created }}<br> <strong>Created:</strong> {{ build.version.created }}<br>
@ -27,52 +27,7 @@
</ul> </ul>
</div> </div>
<div> <div>
<h4>Installed nodes</h4> {% include "mesh/fragment_ota_form.html" %}
<table>
<tr>
<th>{% trans 'Node' %}</th>
<th>{% trans 'Hardware' %}</th>
</tr>
{% for node in installed_nodes %}
<tr>
<td>
{% mesh_node node %}
</td>
<td>
{{ node.board.pretty_name }} ({{ node.chip.pretty_name }})
</td>
</tr>
{% endfor %}
</table>
<h4>Compatible nodes</h4>
<table>
<tr>
<th>{% trans 'Node' %}</th>
<th>{% trans 'Hardware' %}</th>
<th>{% trans 'Current Firmware' %}</th>
</tr>
{% for node in compatible_nodes %}
<tr>
<td>
{% mesh_node node %}
</td>
<td>
{{ node.board.pretty_name }} <small>({{ node.chip.pretty_name }})</small>
</td>
<td>
{% if node.firmware_desc.build %}
<a href="{% url "mesh.firmwares.detail" pk=node.firmware_desc.build.version.pk %}">
{{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}
</a><br>
Build <a href="{% url "mesh.firmwares.build.detail" pk=node.firmware_desc.build.pk %}">{{ node.firmware_desc.build.variant }}</a>
{% else %}
{{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}
{% endif %}
</td>
</tr>
{% endfor %}
</table>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -42,62 +42,7 @@
</table> </table>
</div> </div>
<div> <div>
<h4>Installed nodes</h4> {% include "mesh/fragment_ota_form.html" %}
<table>
<tr>
<th>{% trans 'Node' %}</th>
<th>{% trans 'Hardware' %}</th>
<th>{% trans 'Build' %}</th>
</tr>
{% for node in installed_nodes %}
<tr>
<td>
{% mesh_node node %}
</td>
<td>
{{ node.board.pretty_name }} ({{ node.chip.pretty_name }})
</td>
<td>
{{ node.firmware_desc.build.variant }}
</td>
</tr>
{% endfor %}
</table>
<h4>Compatible nodes</h4>
<table>
<tr>
<th>{% trans 'Node' %}</th>
<th>{% trans 'Hardware' %}</th>
<th>{% trans 'Current Firmware' %}</th>
<th>{% trans 'Compatible Builds' %}</th>
</tr>
{% for node in compatible_nodes %}
<tr>
<td>
{% mesh_node node %}
</td>
<td>
{{ node.board.pretty_name }} <small>({{ node.chip.pretty_name }})</small>
</td>
<td>
{% if node.firmware_desc.build %}
<a href="{% url "mesh.firmwares.detail" pk=node.firmware_desc.build.version.pk %}">
{{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}
</a><br>
Build <a href="{% url "mesh.firmwares.build.detail" pk=node.firmware_desc.build.pk %}">{{ node.firmware_desc.build.variant }}</a>
{% else %}
{{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}
{% endif %}
</td>
<td>
{% for build in node.compatible_builds %}
{{ build.variant }}<br>
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View file

@ -0,0 +1,12 @@
{% if firmware_desc.build %}
<a href="{% url "mesh.firmwares.detail" pk=firmware_desc.build.version.pk %}">
<small>{{ firmware_desc.project_name }}</small>
{{ firmware_desc.version }}
</a><br>
Variant: <a href="{% url "mesh.firmwares.build.detail" pk=firmware_desc.build.pk %}">
{{ firmware_desc.build.variant }}
</a>
{% else %}
<small>{{ firmware_desc.project_name }}</small> {{ firmware_desc.version }}<br>
<small>{{ firmware_desc.sha256_hash }}</small>
{% endif %}

View file

@ -0,0 +1,74 @@
{% load i18n mesh_node %}
<form method="post">
{% csrf_token %}
{% for group in form.groups %}
<h4>{{ group.hardware.board.pretty_name }} ({{ group.hardware.chip.pretty_name }})</h4>
<div style="text-align: right;margin-top: -35px;">
Set
<select style="width: auto;">
<option value="all">all</option>
<option value="older">older</option>
<option value="newer">newer</option>
<option value="different">different</option>
</select>
<select style="width: auto;">
<option value="no-ota">with no OTA</option>
<option value="all">regardless of OTA</option>
</select>
to
{% if group.builds|length == 1 %}
<select style="width: auto;">
<option value="yes">yes</option>
<option value="no">no</option>
</select>
{% else %}
<select style="width: auto;">
<option value="">---</option>
{% for build in group.builds %}
<option value="{{ build.pk }}">{{ build.variant }}</option>
{% endfor %}
</select>
{% endif %}
<button type="button">{% trans 'Set' %}</button>
</div>
<table>
<tr>
<th>{% trans 'Node' %}</th>
<th>{% trans 'Current Firmware' %}</th>
<th>{% trans 'Current OTA' %}</th>
<th>
{% if group.builds|length == 1 %}
Install {{ group.builds.0.variant }}
{% else %}
{% trans 'Build to install' %}
{% endif %}
</th>
</tr>
{% for node, field in group.fields.values %}
<tr>
<td>
{% mesh_node node %}
</td>
<td>
{% include "mesh/fragment_firmware_cell.html" with firmware_desc=node.firmware_desc %}
</td>
<td>
{% if node.current_ota %}
<a>#{{ node.current_ota.update.pk }} <small>({{ node.current_ota.update.created }})</small></a><br>
{% include "mesh/fragment_firmware_cell.html" with firmware_desc=node.current_ota.update.build.get_firmware_description %}
{% else %}
-
{% endif %}
</td>
<td>
{{ field }}
</td>
</tr>
{% endfor %}
</table>
{% endfor %}
<div style="text-align: right">
<button type="submit">{% trans 'Start OTA' %}</button>
</div>
</form>

View file

@ -21,15 +21,7 @@
<small>({{ 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 }})</small> <small>({{ 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 }})</small>
</td> </td>
<td> <td>
{% if node.firmware_desc.build %} {% include "mesh/fragment_firmware_cell.html" with firmware_desc=node.firmware_desc %}
<a href="{% url "mesh.firmwares.detail" pk=node.firmware_desc.build.firmware.pk %}">{{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}</a><br>
<a href="{% url "mesh.firmwares.build.detail" pk=node.firmware_desc.build.pk %}">
{{ node.firmware_desc.build.variant }}
</a>
{% else %}
{{ node.firmware_desc.project_name }} {{ node.firmware_desc.version }}<br>
<small>{{ node.firmware_desc.sha256_hash }}</small>
{% endif %}
</td> </td>
<td> <td>
{% blocktrans trimmed with timesince=node.last_messages.any.datetime|timesince %} {% blocktrans trimmed with timesince=node.last_messages.any.datetime|timesince %}

View file

@ -1,8 +1,10 @@
from django.contrib.messages.views import SuccessMessageMixin
from django.views.generic import DetailView, ListView, TemplateView 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.models import FirmwareBuild, FirmwareVersion, MeshNode
from c3nav.mesh.views.base import MeshControlMixin from c3nav.mesh.views.base import MeshControlMixin
from c3nav.site.forms import OTACreateForm
class FirmwaresListView(MeshControlMixin, ListView): 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 model = FirmwareVersion
template_name = "mesh/firmware_detail.html" template_name = "mesh/firmware_detail.html"
context_object_name = "firmware" context_object_name = "firmware"
@ -41,38 +58,21 @@ class FirmwareDetailView(MeshControlMixin, DetailView):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().prefetch_related('builds', 'builds__firmwarebuildboard_set') 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): def get_context_data(self, **kwargs):
ctx = super().get_context_data() 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({ ctx.update({
'builds': builds, 'builds': self.get_object().builds.all(),
'installed_nodes': installed_nodes,
'compatible_nodes': compatible_nodes,
}) })
return ctx return ctx
class FirmwareBuildDetailView(MeshControlMixin, DetailView): class FirmwareBuildDetailView(OTACreateMixin, MeshControlMixin, DetailView):
model = FirmwareBuild model = FirmwareBuild
template_name = "mesh/firmware_build_detail.html" template_name = "mesh/firmware_build_detail.html"
context_object_name = "build" context_object_name = "build"
@ -80,27 +80,8 @@ class FirmwareBuildDetailView(MeshControlMixin, DetailView):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().prefetch_related('firmwarebuildboard_set') return super().get_queryset().prefetch_related('firmwarebuildboard_set')
def get_context_data(self, **kwargs): def get_form_kwargs(self):
ctx = super().get_context_data() return {
**super().get_form_kwargs(),
nodes = list(MeshNode.objects.all().prefetch_firmwares().prefetch_last_messages( 'builds': [self.get_object()],
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

View file

@ -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 operator import attrgetter
from typing import Any, Sequence
from django.db import transaction 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 django.utils.translation import gettext_lazy as _
from c3nav.mapdata.forms import I18nModelFormMixin from c3nav.mapdata.forms import I18nModelFormMixin
from c3nav.mapdata.models.locations import Position from c3nav.mapdata.models.locations import Position
from c3nav.mapdata.models.report import Report, ReportUpdate 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): class ReportIssueForm(I18nModelFormMixin, ModelForm):
@ -65,3 +71,92 @@ class PositionSetForm(Form):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['position'].queryset = Position.objects.filter(owner=request.user) self.fields['position'].queryset = Position.objects.filter(owner=request.user)
self.fields['position'].label_from_instance = attrgetter('name') 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

View file

@ -1365,3 +1365,6 @@ button + button {
padding: 2px; padding: 2px;
} }
} }
table td select:last-child {
margin-bottom: 0;
}