Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Dev/foxglove dashboards #16

Closed
wants to merge 6 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ requiring to access the device database.

> | name | type | data type | description |
> |-----------|-----------|-------------------------|-----------------------------------------------------------------------|
> | None | required | {"uid": "string", "address": "string", "grafana_dashboards"(optional): "list(grafana_json_dashboards)"} | Unique ID and IP address of the device. Grafana dashboards is an optional list of Grafana JSON dashboards |
> | None | required | {"uid": "string", "address": "string", "grafana_dashboards"(optional): "list(grafana_json_dashboards)", "foxglove_layouts"(optional): "dict( "layout_name": {grafana_json_layouts})"} | Unique ID and IP address of the device. `grafana_dashboards` is an optional list of Grafana JSON dashboards. `foxglove_layouts` is an optional dict of layout names and the associated Foxlglove JSON layout. |


##### Responses
Expand Down Expand Up @@ -109,7 +109,7 @@ requiring to access the device database.

> | name | type | data type | description |
> |-----------|-----------|-------------------------|-----------------------------------------------------------------------|
> | None | required | {"field: "value"} | Field to modify. Can be: address and grafana_dashboards |
> | None | required | {"field: "value"} | Field to modify. Can be: address, grafana_dashboards or and foxglove_layouts |


##### Responses
Expand All @@ -136,6 +136,21 @@ requiring to access the device database.
> | `404` | `text/html;charset=utf-8` | None |
</details>

<details>
<summary><code>GET</code> <code><b>api/v1/device/&#60str:uid&#62;/foxglove_layouts/&#60str:layout_uid&#62;</b></code> <code>(Download the layout json)</code></summary>

#### Parameters

> None

#### Responses

> | http code | content-type | response |
> |---------------|-----------------------------------|---------------------------------------------------------------------|
> | `200` | `application/json; Content-Disposition attachment; filename=layout_uid.json` | Json file |
> | `404` | `text/html;charset=utf-8` | None |
</details>

## Installation
First we must generate a secret key for our django to sign data.
The secret key must be a large random value and it must be kept secret.
Expand Down
37 changes: 37 additions & 0 deletions cos_registration_server/api/serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ class DeviceSerializer(serializers.Serializer):
creation_date = serializers.DateTimeField(read_only=True)
address = serializers.IPAddressField(required=True)
grafana_dashboards = serializers.JSONField(required=False)
foxglove_layouts = serializers.JSONField(required=False)

def create(self, validated_data):
"""Create Device object from data.
Expand All @@ -36,6 +37,10 @@ def update(self, instance, validated_data):
"grafana_dashboards", instance.grafana_dashboards
)
instance.grafana_dashboards = grafana_dashboards
foxglove_layouts = validated_data.get(
"foxglove_layouts", instance.foxglove_layouts
)
instance.foxglove_layouts = foxglove_layouts
instance.save()
return instance

Expand All @@ -62,3 +67,35 @@ def validate_grafana_dashboards(self, value):
"gafana_dashboards is not a supported format (list)."
)
return dashboards

def validate_foxglove_layouts(self, value):
"""Validate foxglove layouts data.

value: Foxglove layouts provided data.
return: Foxglove layouts as dict.
raise:
json.JSONDecodeError
serializers.ValidationError
"""
if isinstance(value, str):
Guillaumebeuzeboc marked this conversation as resolved.
Show resolved Hide resolved
try:
layouts = json.loads(value)
except json.JSONDecodeError:
raise serializers.ValidationError(
"Failed to load foxglove_layouts as json."
)
else:
layouts = value
if not isinstance(layouts, dict):
raise serializers.ValidationError(
"foxglove_layouts is not a supported format (dict)."
)

for key, value in layouts.items():
if not isinstance(key, str) or not isinstance(value, dict):
raise serializers.ValidationError(
'foxglove_layouts should be passed with a name. \
{"my_name": {foxglove_layout...} }'
)

return layouts
141 changes: 130 additions & 11 deletions cos_registration_server/api/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
from pathlib import Path
from shutil import rmtree

from devices.models import Device, default_dashboards_json_field
from devices.models import (
Device,
default_dashboards_json_field,
default_layouts_json_field,
)
from django.core.management import call_command
from django.test import TestCase
from django.urls import reverse
Expand All @@ -28,6 +32,15 @@ def setUp(self):
"refresh": "25s",
}

self.simple_foxglove_layouts = {
"simple_layout": {
"configById": {},
"globalVariables": {},
"userNodes": {},
"playbackConfig": {"speed": 1},
}
}

def create_device(self, **fields):
data = {}
for field, value in fields.items():
Expand All @@ -48,6 +61,7 @@ def test_create_device(self):
uid=uid,
address=address,
grafana_dashboards=custom_grafana_dashboards,
foxglove_layouts=self.simple_foxglove_layouts,
)
self.assertEqual(response.status_code, 201)
self.assertEqual(Device.objects.count(), 1)
Expand All @@ -70,6 +84,9 @@ def test_create_device(self):
"title"
] = f'{uid}-{self.simple_grafana_dashboard["title"]}'
self.assertEqual(dashboard_data, self.simple_grafana_dashboard)
self.assertEqual(
Device.objects.get().foxglove_layouts, self.simple_foxglove_layouts
)

def test_create_multiple_devices(self):
devices = [
Expand Down Expand Up @@ -138,6 +155,56 @@ def test_grafana_dashboard_illformed_json(self):
status_code=400,
)

def test_foxglove_layouts_not_in_a_dict(self):
uid = "robot-1"
address = "192.168.0.1"
response = self.create_device(
uid=uid,
address=address,
foxglove_layouts=[self.simple_foxglove_layouts],
)
self.assertEqual(Device.objects.count(), 0)
self.assertContains(
response,
'{"foxglove_layouts": ["foxglove_layouts is not a supported '
"format (dict).",
status_code=400,
)

def test_foxglove_layouts_illformed_json(self):
uid = "robot-1"
address = "192.168.0.1"
response = self.create_device(
uid=uid,
address=address,
foxglove_layouts='{{"hello"=321}',
)
self.assertEqual(Device.objects.count(), 0)
self.assertContains(
response,
'{"foxglove_layouts": ["Failed to load foxglove_layouts '
"as json.",
status_code=400,
)

def test_foxglove_layouts_not_a_layout(self):
uid = "robot-1"
address = "192.168.0.1"
custom_foxglove_layouts = {"layout": "not a layout"}
response = self.create_device(
uid=uid,
address=address,
foxglove_layouts=custom_foxglove_layouts,
)
self.assertEqual(Device.objects.count(), 0)
self.assertContains(
response,
'{"foxglove_layouts": '
'["foxglove_layouts should be passed with a name. \
{\\"my_name\\": {foxglove_layout...} }"]}',
status_code=400,
)


class DeviceViewTests(APITestCase):
def setUp(self):
Expand All @@ -154,7 +221,16 @@ def setUp(self):
"refresh": "25s",
}

def url(self, uid):
self.simple_foxglove_layouts = {
"simple_layout": {
"configById": {},
"globalVariables": {},
"userNodes": {},
"playbackConfig": {"speed": 1},
}
}

def url_device(self, uid):
return reverse("api:device", args=(uid,))

def create_device(self, **fields):
Expand All @@ -165,14 +241,14 @@ def create_device(self, **fields):
return self.client.post(url, data, format="json")

def test_get_nonexistent_device(self):
response = self.client.get(self.url("future-robot"))
response = self.client.get(self.url_device("future-robot"))
self.assertEqual(response.status_code, 404)

def test_get_device(self):
uid = "robot-1"
address = "192.168.1.2"
self.create_device(uid=uid, address=address)
response = self.client.get(self.url(uid))
response = self.client.get(self.url_device(uid))
self.assertEqual(response.status_code, 200)
content_json = json.loads(response.content)
self.assertEqual(content_json["uid"], uid)
Expand All @@ -191,18 +267,18 @@ def test_patch_device(self):
self.create_device(uid=uid, address=address)
address = "192.168.1.200"
data = {"address": address}
response = self.client.patch(self.url(uid), data, format="json")
response = self.client.patch(self.url_device(uid), data, format="json")
self.assertEqual(response.status_code, 200)
content_json = json.loads(response.content)
self.assertEqual(content_json["uid"], uid)
self.assertEqual(content_json["address"], address)

def test_patch_dashboards(self):
def test_patch_grafana_dashboards(self):
uid = "robot-1"
address = "192.168.1.2"
self.create_device(uid=uid, address=address)
data = {"grafana_dashboards": [self.simple_grafana_dashboard]}
response = self.client.patch(self.url(uid), data, format="json")
response = self.client.patch(self.url_device(uid), data, format="json")
self.assertEqual(response.status_code, 200)
content_json = json.loads(response.content)
# necessary since patching returns the modified title
Expand All @@ -221,13 +297,26 @@ def test_patch_dashboards(self):
dashboard_data = json.load(file)
self.assertEqual(dashboard_data, self.simple_grafana_dashboard)

def test_patch_foxglove_layouts(self):
uid = "robot-1"
address = "192.168.1.2"
self.create_device(uid=uid, address=address)
data = {"foxglove_layouts": self.simple_foxglove_layouts}
response = self.client.patch(self.url_device(uid), data, format="json")
self.assertEqual(response.status_code, 200)
content_json = json.loads(response.content)
self.assertEqual(
content_json["foxglove_layouts"],
self.simple_foxglove_layouts,
)

def test_invalid_patch_device(self):
uid = "robot-1"
address = "192.168.1.2"
self.create_device(uid=uid, address=address)
address = "192.168.1" # invalid IP
data = {"address": address}
response = self.client.patch(self.url(uid), data, format="json")
response = self.client.patch(self.url_device(uid), data, format="json")
self.assertEqual(response.status_code, 400)

def test_delete_device(self):
Expand All @@ -238,17 +327,17 @@ def test_delete_device(self):
address=address,
grafana_dashboards=[self.simple_grafana_dashboard],
)
response = self.client.get(self.url(uid))
response = self.client.get(self.url_device(uid))
self.assertEqual(response.status_code, 200)
self.assertTrue(
path.isfile(
self.grafana_dashboards_path
/ "robot-1-Production_Overview.json"
)
)
response = self.client.delete(self.url(uid))
response = self.client.delete(self.url_device(uid))
self.assertEqual(response.status_code, 204)
response = self.client.get(self.url(uid))
response = self.client.get(self.url_device(uid))
self.assertEqual(response.status_code, 404)
self.assertFalse(
path.isfile(
Expand All @@ -257,6 +346,36 @@ def test_delete_device(self):
)
)

def test_get_foxglove_layout(self):
uid = "robot-1"
address = "192.168.1.2"
self.create_device(
uid=uid,
address=address,
foxglove_layouts=self.simple_foxglove_layouts,
)
response = self.client.get(
reverse("api:foxglove_layout", args=(uid, "simple_layout"))
)
self.assertEqual(response.status_code, 200)
content_json = json.loads(response.content)
self.assertEqual(
content_json, self.simple_foxglove_layouts["simple_layout"]
)

def test_get_wrong_foxglove_dashboard(self):
uid = "robot-1"
address = "192.168.1.2"
self.create_device(
uid=uid,
address=address,
foxglove_layouts=self.simple_foxglove_layouts,
)
response = self.client.get(
reverse("api:foxglove_layout", args=(uid, "wrong_layout"))
)
self.assertEqual(response.status_code, 404)


class CommandsTestCase(TestCase):
def setUp(self):
Expand Down
5 changes: 5 additions & 0 deletions cos_registration_server/api/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@
# make a version API url
path("v1/devices", views.devices, name="devices"),
path("v1/devices/<str:uid>", views.device, name="device"),
path(
"v1/devices/<str:uid>/foxglove_layouts/<str:layout_uid>",
Copy link

Choose a reason for hiding this comment

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

Wouldn't it be better for the layout_uid to be a query param ? foxglove_layouts/some_name vs foxglove_layouts?layout=some_name.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

No strong opinions but usually path are for resources on the server and parameters for performing action (download a file, connect to a remote, sort, filter).
The stack exchange doc seems to follow that: https://api.stackexchange.com/docs

Copy link

Choose a reason for hiding this comment

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

My (rough) understanding was that paths are for things that won't change while params are for things that may change (e.g. v1 or devices will never change while the name of a layout may change).
That's why we need to put that on paper ^^

views.foxglove_layout,
name="foxglove_layout",
),
]
26 changes: 26 additions & 0 deletions cos_registration_server/api/views.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
"""API views."""
import json

from api.serializer import DeviceSerializer
from devices.models import Device
from django.http import HttpResponse, JsonResponse
Expand Down Expand Up @@ -60,3 +62,27 @@ def device(request, uid):
device.delete()
delete_grafana_dashboards(device)
return HttpResponse(status=204)


def foxglove_layout(request, uid, layout_uid):
"""Foxglove layout json file API.

request: Http request (GET).
uid: Device UID passed in the URL.
layout_uid: Foxglove layout uid.
return: Http JSON file response.
"""
if request.method == "GET":
try:
device = Device.objects.get(uid=uid)
except Device.DoesNotExist:
return HttpResponse(status=404)
if (layout := device.foxglove_layouts.get(layout_uid)) is None:
return HttpResponse(status=404)
response = HttpResponse(
json.dumps(layout), content_type="application/json"
)
response[
"Content-Disposition"
] = f'attachment; filename="{layout_uid}.json"'
return response
Loading
Loading