OTA status views etc

This commit is contained in:
Laura Klünder 2023-11-26 16:22:55 +01:00
parent 5953bc9acc
commit ade1807abb
10 changed files with 247 additions and 8 deletions

View file

@ -0,0 +1,78 @@
# Generated by Django 4.2.1 on 2023-11-26 14:43
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("mesh", "0011_meshnode_address_validate"),
]
operations = [
migrations.AddField(
model_name="otaupdaterecipient",
name="status",
field=models.CharField(
choices=[
("running", "running"),
("replaced", "replaced"),
("canceled", "canceled"),
("failed", "failed"),
("success", "success"),
],
default="running",
max_length=10,
verbose_name="status",
),
),
migrations.AlterField(
model_name="nodemessage",
name="message_type",
field=models.CharField(
choices=[
("NOOP", "noop"),
("ECHO_REQUEST", "echo request"),
("ECHO_RESPONSE", "echo response"),
("MESH_SIGNIN", "mesh signin"),
("MESH_LAYER_ANNOUNCE", "mesh layer announce"),
("MESH_ADD_DESTINATIONS", "mesh add destinations"),
("MESH_REMOVE_DESTINATIONS", "mesh remove destinations"),
("MESH_ROUTE_REQUEST", "mesh route request"),
("MESH_ROUTE_RESPONSE", "mesh route response"),
("MESH_ROUTE_TRACE", "mesh route trace"),
("MESH_ROUTING_FAILED", "mesh routing failed"),
("CONFIG_DUMP", "dump config"),
("CONFIG_HARDWARE", "hardware config"),
("CONFIG_BOARD", "board config"),
("CONFIG_FIRMWARE", "firmware config"),
("CONFIG_UPLINK", "uplink config"),
("CONFIG_POSITION", "position config"),
("OTA_STATUS", "ota status"),
("OTA_REQUEST_STATUS", "ota request status"),
("OTA_START", "ota start"),
("OTA_URL", "ota url"),
("OTA_FRAGMENT", "ota fragment"),
("OTA_REQUEST_FRAGMENTS", "ota request fragments"),
("OTA_SETTING", "ota setting"),
("OTA_APPLY", "ota apply"),
("OTA_ABORT", "ota abort"),
("LOCATE_REQUEST_RANGE", "locate request range"),
("LOCATE_RANGE_RESULTS", "locate range results"),
("LOCATE_RAW_FTM_RESULTS", "locate raw ftm results"),
("REBOOT", "reboot"),
("REPORT_ERROR", "report error"),
],
db_index=True,
max_length=24,
verbose_name="message type",
),
),
migrations.AddConstraint(
model_name="otaupdaterecipient",
constraint=models.UniqueConstraint(
condition=models.Q(("status", "running")),
fields=("node",),
name="only_one_active_ota",
),
),
]

View file

@ -164,9 +164,10 @@ class MeshNodeQuerySet(models.QuerySet):
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(
for ota in OTAUpdateRecipient.objects.filter(
node__in=nodes.keys(),
).select_related("update", "update__build").distinct('node'):
status=OTARecipientStatus.RUNNING,
).select_related("update", "update__build"):
# noinspection PyUnresolvedReferences
nodes[ota.node_id]._current_ota = ota
for node in nodes.values():
@ -260,7 +261,9 @@ class MeshNode(models.Model):
# noinspection PyUnresolvedReferences
return self._current_ota
except AttributeError:
return self.ota_updates.order_by('-update__created').select_related("update", "update__build").first()
return self.ota_updates.select_related("update", "update__build").filter(
status=OTARecipientStatus.RUNNING
).first()
@cached_property
def ranging_beacon(self) -> Optional["RangingBeacon"]:
@ -477,8 +480,31 @@ class OTAUpdate(models.Model):
build = models.ForeignKey(FirmwareBuild, on_delete=models.CASCADE)
created = models.DateTimeField(_('creation'), auto_now_add=True)
@property
def grouped_recipients(self):
result = {}
for recipient in self.recipients.all():
result.setdefault(recipient.get_status_display(), []).append(recipient)
return result
class OTARecipientStatus(models.TextChoices):
RUNNING = "running", _("running")
REPLACED = "replaced", _("replaced")
CANCELED = "canceled", _("canceled")
FAILED = "failed", _("failed")
SUCCESS = "success", _("success")
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'))
status = models.CharField(max_length=10, choices=OTARecipientStatus.choices, default=OTARecipientStatus.RUNNING,
verbose_name=_('status'))
class Meta:
constraints = (
UniqueConstraint(fields=["node"], condition=Q(status=OTARecipientStatus.RUNNING),
name='only_one_active_ota'),
)

View file

@ -18,6 +18,7 @@
<a href="{% url 'mesh.nodes' %}">Nodes</a> &middot;
<a href="{% url 'mesh.messages' %}">Messages</a> &middot;
<a href="{% url 'mesh.firmwares' %}">Firmwares</a> &middot;
<a href="{% url 'mesh.ota.list' %}">OTA</a> &middot;
<a href="{% url 'mesh.ranging' %}">Ranging</a> &middot;
<a href="{% url 'mesh.logs' %}">Live logs</a> &middot;
</p>

View file

@ -55,7 +55,7 @@
</td>
<td>
{% if node.current_ota %}
<a>#{{ node.current_ota.update.pk }} <small>({{ node.current_ota.update.created }})</small></a><br>
<a href="{% url 'mesh.ota.detail' pk=node.current_ota.update.pk %}">#{{ 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.firmware_description %}
{% else %}
-

View file

@ -0,0 +1,37 @@
{% extends 'mesh/base.html' %}
{% load i18n mesh_node %}
{% block heading %}{% trans 'OTA Update' %}{% endblock %}
{% block subcontent %}
<div class="columns">
<div>
<h4>{% trans 'Firmware Build' %}</h4>
<strong>Firmware:</strong> <a href="{% url "mesh.firmwares.detail" pk=update.build.version.pk %}">
{{ update.build.version.project_name }}
{{ update.build.version.version }}
</a><br>
<strong>Build:</strong> <a href="{% url "mesh.firmwares.build.detail" pk=update.build.pk %}">
{{ update.build.variant }}
</a><br>
<strong>Created:</strong> {{ update.created }}
</div>
<div>
<table>
<tr>
<th>{% trans 'Node' %}</th>
<th>{% trans 'Status' %}</th>
<th>{% trans 'Progress' %}</th>
</tr>
{% for recipient in update.recipients.all %}
<tr>
<td>{% mesh_node recipient.node %}</td>
<td>{{ recipient.get_status_display}}</td>
<td></td>
</tr>
{% endfor %}
</table>
</div>
</div>
{% endblock %}

View file

@ -0,0 +1,56 @@
{% extends 'mesh/base.html' %}
{% load i18n %}
{% block heading %}{% if all %}{% trans 'All OTA Updates' %}{% else %}{% trans 'Running OTA Updates' %}{% endif %}{% endblock %}
{% block subcontent %}
{% if all %}
<a class="button" href="{% url "mesh.ota.list" %}">
{% trans 'View running OTA updates only' %}
</a>
{% else %}
<a class="button" href="{% url "mesh.ota.list.all" %}">
{% trans 'View all OTA updates' %}
</a>
{% endif %}
{% if all %}
{% include 'control/fragment_pagination.html' %}
{% endif %}
<table>
<tr>
<th>{% trans 'ID' %}</th>
<th>{% trans 'Created' %}</th>
<th>{% trans 'Firmware' %}</th>
<th>{% trans 'Recipients' %}</th>
</tr>
{% for update in updates %}
<tr>
<td><a href="{% url 'mesh.ota.detail' pk=update.pk %}">{{ update.pk }}</a></td>
<td>{{ update.created }}</td>
<td>
<a href="{% url "mesh.firmwares.detail" pk=update.build.version.pk %}">
{{ update.build.version.project_name }}
{{ update.build.version.version }}
</a><br>
Variant: <a href="{% url "mesh.firmwares.build.detail" pk=update.build.pk %}">
{{ update.build.variant }}
</a>
</td>
<td>
<a href="{% url 'mesh.ota.detail' pk=update.pk %}">{{ update.recipients.all | length }} recipients</a><br>
{% for status, recipients in update.grouped_recipients.items %}
{% if forloop.counter0 > 0 %} &bull; {% endif %}
{{ status }}: {{ recipients|length }}
{% endfor %}
</td>
</tr>
{% endfor %}
</table>
{% if all %}
{% include 'control/fragment_pagination.html' %}
{% endif %}
{% endblock %}

View file

@ -2,7 +2,7 @@ from django.urls import path
from c3nav.mesh.consumers import MeshConsumer, MeshUIConsumer
from c3nav.mesh.views.firmware import (FirmwareBuildDetailView, FirmwareDetailView, FirmwaresCurrentListView,
FirmwaresListView)
FirmwaresListView, OTAListView, OTADetailView)
from c3nav.mesh.views.messages import MeshMessageListView, MeshMessageSendingView, MeshMessageSendView
from c3nav.mesh.views.misc import MeshLogView, MeshRangingView
from c3nav.mesh.views.nodes import NodeDetailView, NodeEditView, NodeListView
@ -15,6 +15,9 @@ urlpatterns = [
path('firmwares/current/', FirmwaresCurrentListView.as_view(), name='mesh.firmwares.current'),
path('firmwares/<int:pk>/', FirmwareDetailView.as_view(), name='mesh.firmwares.detail'),
path('firmwares/builds/<int:pk>/', FirmwareBuildDetailView.as_view(), name='mesh.firmwares.build.detail'),
path('ota/', OTAListView.as_view(), name='mesh.ota.list'),
path('ota/all/', OTAListView.as_view(all=True), name='mesh.ota.list.all'),
path('ota/<int:pk>/', OTADetailView.as_view(), name='mesh.ota.detail'),
path('nodes/<str:pk>/', NodeDetailView.as_view(), name='mesh.node.detail'),
path('nodes/<str:pk>/edit/', NodeEditView.as_view(), name='mesh.node.edit'),
path('message/sending/<uuid:uuid>/', MeshMessageSendingView.as_view(), name='mesh.sending'),

View file

@ -2,7 +2,7 @@ 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.models import FirmwareBuild, FirmwareVersion, MeshNode
from c3nav.mesh.models import FirmwareBuild, FirmwareVersion, MeshNode, OTAUpdate, OTARecipientStatus
from c3nav.mesh.views.base import MeshControlMixin
from c3nav.site.forms import OTACreateForm
@ -87,3 +87,35 @@ class FirmwareBuildDetailView(OTACreateMixin, MeshControlMixin, DetailView):
**super().get_form_kwargs(),
'builds': [self.get_object()],
}
class OTAListView(ListView):
model = OTAUpdate
template_name = "mesh/ota_list.html"
ordering = "-created"
context_object_name = "updates"
all = False
def get_queryset(self):
qs = super().get_queryset().prefetch_related('recipients')
if self.all:
qs = qs.filter(recipients__status=OTARecipientStatus.RUNNING)
return qs
def get_context_data(self, **kwargs):
return {
**super().get_context_data(),
"all": self.all,
}
def get_paginate_by(self, queryset):
return 20 if self.all else None
class OTADetailView(MeshControlMixin, DetailView):
model = OTAUpdate
template_name = "mesh/ota_detail.html"
context_object_name = "update"
def get_queryset(self):
return super().get_queryset().prefetch_related('recipients', 'recipients__node')

View file

@ -19,7 +19,6 @@ class NodeListView(MeshControlMixin, ListView):
return super().get_queryset().prefetch_last_messages().prefetch_firmwares().prefetch_ranging_beacon()
class NodeDetailView(MeshControlMixin, DetailView):
model = MeshNode
template_name = "mesh/node_detail.html"

View file

@ -1,6 +1,7 @@
from dataclasses import dataclass
from dataclasses import replace as dataclass_replace
from functools import cached_property
from itertools import chain
from operator import attrgetter
from typing import Any, Sequence
@ -12,7 +13,8 @@ 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
from c3nav.mesh.models import FirmwareBuild, HardwareDescription, MeshNode, OTAUpdate, OTAUpdateRecipient, \
OTARecipientStatus
class ReportIssueForm(I18nModelFormMixin, ModelForm):
@ -154,6 +156,11 @@ class OTACreateForm(Form):
def save(self) -> list[OTAUpdate]:
updates = []
with transaction.atomic():
replaced_recipients = OTAUpdateRecipient.objects.filter(
node__in=chain(*self.selected_builds.values()),
status=OTARecipientStatus.RUNNING,
).select_for_update()
replaced_recipients.update(status=OTARecipientStatus.REPLACED)
for build, nodes in self.selected_builds.items():
update = OTAUpdate.objects.create(build=build)
for node in nodes: