diff --git a/pyproject.toml b/pyproject.toml index 62dc05d..e340d23 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ classifiers = [ "Homepage" = "https://github.com/Aiky30/shed-pi" "Bug Tracker" = "https://github.com/Aiky30/shed-pi/issues" +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "shedpi_hub_example_project.settings" + [tool.black] exclude = ''' /( diff --git a/requirements.txt b/requirements.txt index d1eff23..4c91d69 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,11 +1,17 @@ asgiref==3.7.2 +attrs==23.2.0 build==0.10.0 cfgv==3.4.0 distlib==0.3.8 Django==5.0.1 +exceptiongroup==1.2.0 +factory-boy==3.3.0 +Faker==22.5.0 filelock==3.13.1 identify==2.5.33 iniconfig==2.0.0 +jsonschema==4.21.1 +jsonschema-specifications==2023.12.1 nodeenv==1.8.0 packaging==23.1 platformdirs==4.1.0 @@ -13,8 +19,13 @@ pluggy==1.4.0 pre-commit==3.6.0 pyproject_hooks==1.0.0 pytest==7.4.4 +pytest-django==4.7.0 +python-dateutil==2.8.2 PyYAML==6.0.1 +referencing==0.32.1 +rpds-py==0.17.1 setuptools-scm==7.1.0 +six==1.16.0 sqlparse==0.4.4 tomli==2.0.1 typing_extensions==4.7.1 diff --git a/shedpi_hub_dashboard/admin.py b/shedpi_hub_dashboard/admin.py index 8726e78..66ff110 100644 --- a/shedpi_hub_dashboard/admin.py +++ b/shedpi_hub_dashboard/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from .models import Device, DeviceReading +from .models import Device, DeviceModule, DeviceModuleReading @admin.register(Device) @@ -8,6 +8,11 @@ class DeviceAdmin(admin.ModelAdmin): list_display = ("id", "name") -@admin.register(DeviceReading) -class DeviceReadingAdmin(admin.ModelAdmin): +@admin.register(DeviceModule) +class DeviceModuleAdmin(admin.ModelAdmin): + pass + + +@admin.register(DeviceModuleReading) +class DeviceModuleReadingAdmin(admin.ModelAdmin): pass diff --git a/shedpi_hub_dashboard/forms/__init__.py b/shedpi_hub_dashboard/forms/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shedpi_hub_dashboard/forms/fields.py b/shedpi_hub_dashboard/forms/fields.py new file mode 100644 index 0000000..c0bc3cd --- /dev/null +++ b/shedpi_hub_dashboard/forms/fields.py @@ -0,0 +1,25 @@ +import json + +from django.forms import JSONField as JSONFormField +from django.forms import widgets + + +class PrettyJSONWidget(widgets.Textarea): + def format_value(self, value): + # Prettify the json + value = json.dumps(json.loads(value), indent=2, sort_keys=True) + + # Calculate the size of the contents + row_lengths = [len(r) for r in value.split("\n")] + content_width = max(len(row_lengths) + 2, 20) + content_height = max(max(row_lengths) + 2, 45) + + # Adjust the size of TextArea to fit to content + self.attrs["rows"] = min(content_width, 60) + self.attrs["cols"] = min(content_height, 155) + + return value + + +class PrettyJsonFormField(JSONFormField): + widget = PrettyJSONWidget diff --git a/shedpi_hub_dashboard/migrations/0001_initial.py b/shedpi_hub_dashboard/migrations/0001_initial.py index 9478abe..c1ab5af 100644 --- a/shedpi_hub_dashboard/migrations/0001_initial.py +++ b/shedpi_hub_dashboard/migrations/0001_initial.py @@ -1,6 +1,7 @@ -# Generated by Django 5.0.1 on 2024-01-17 22:11 +# Generated by Django 5.0.1 on 2024-01-23 19:02 import django.db.models.deletion +import shedpi_hub_dashboard.models import uuid from django.db import migrations, models @@ -28,7 +29,35 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name="DeviceReading", + name="DeviceModule", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(max_length=20)), + ("location", models.CharField(max_length=50)), + ( + "schema", + shedpi_hub_dashboard.models.PrettySONField(blank=True, null=True), + ), + ( + "device", + models.ForeignKey( + help_text="A device which manages the module.", + on_delete=django.db.models.deletion.CASCADE, + to="shedpi_hub_dashboard.device", + ), + ), + ], + ), + migrations.CreateModel( + name="DeviceModuleReading", fields=[ ( "id", @@ -39,16 +68,17 @@ class Migration(migrations.Migration): verbose_name="ID", ), ), - ("device_temp", models.CharField(max_length=8)), - ("probe_temp", models.CharField(max_length=8)), - ("measurement_type", models.CharField(max_length=10)), - ("datetime", models.DateTimeField()), ( - "device", + "data", + shedpi_hub_dashboard.models.PrettySONField(blank=True, null=True), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "device_module", models.ForeignKey( help_text="A device whose readings were collected.", on_delete=django.db.models.deletion.CASCADE, - to="shedpi_hub_dashboard.device", + to="shedpi_hub_dashboard.devicemodule", ), ), ], diff --git a/shedpi_hub_dashboard/models.py b/shedpi_hub_dashboard/models.py index fb6bd45..b2b3748 100644 --- a/shedpi_hub_dashboard/models.py +++ b/shedpi_hub_dashboard/models.py @@ -1,6 +1,17 @@ import uuid from django.db import models +from django.db.models import JSONField +from jsonschema import validate + +from shedpi_hub_dashboard.forms.fields import PrettyJsonFormField + + +class PrettySONField(JSONField): + def formfield(self, **kwargs): + defaults = {"form_class": PrettyJsonFormField} + defaults.update(kwargs) + return super().formfield(**defaults) class Device(models.Model): @@ -12,14 +23,48 @@ def __str__(self): return self.name -class DeviceReading(models.Model): +class DeviceModule(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) device = models.ForeignKey( Device, on_delete=models.CASCADE, + help_text="A device which manages the module.", + ) + name = models.CharField(max_length=20) + location = models.CharField(max_length=50) + schema = PrettySONField(null=True, blank=True) + + def __str__(self): + return self.name + + +class DeviceModuleReading(models.Model): + device_module = models.ForeignKey( + DeviceModule, + on_delete=models.CASCADE, help_text="A device whose readings were collected.", ) - # TODO: Create a Json configurable engine for storage and retrieval fieldsĀ¬ - device_temp = models.CharField(max_length=8) - probe_temp = models.CharField(max_length=8) - measurement_type = models.CharField(max_length=10) - datetime = models.DateTimeField() + data = PrettySONField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + def validate_data(self) -> None: + """ + Validates the data against the schema defined in the DeviceModule, + the purpose is to ensure that the data stored matches the data structure expected + """ + schema = self.device_module.schema + + # Only validate if a schema exists + if schema: + validate( + instance=self.data, + schema=schema, + ) + + def save(self, *args, **kwargs) -> None: + """ + On save validates + """ + self.validate_data() + + super().save(*args, **kwargs) diff --git a/shedpi_hub_dashboard/tests.py b/shedpi_hub_dashboard/tests.py deleted file mode 100644 index a39b155..0000000 --- a/shedpi_hub_dashboard/tests.py +++ /dev/null @@ -1 +0,0 @@ -# Create your tests here. diff --git a/shedpi_hub_dashboard/tests/test_json_schema.py b/shedpi_hub_dashboard/tests/test_json_schema.py new file mode 100644 index 0000000..307218a --- /dev/null +++ b/shedpi_hub_dashboard/tests/test_json_schema.py @@ -0,0 +1,98 @@ +import pytest +from jsonschema.exceptions import SchemaError, ValidationError + +from shedpi_hub_dashboard.models import DeviceModuleReading +from shedpi_hub_dashboard.tests.utils.factories import DeviceModuleFactory + + +@pytest.mark.django_db +def test_schema_validation_happy_path(): + """ + The data validation should succeed when the schema is valid + """ + schema = { + "$id": "https://example.com/person.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Person", + "type": "object", + "properties": { + "firstName": {"type": "string", "description": "The person's first name."}, + "lastName": {"type": "string", "description": "The person's last name."}, + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0, + }, + }, + } + data = {"firstName": "John", "lastName": "Doe", "age": 21} + device_module = DeviceModuleFactory(schema=schema) + + reading = DeviceModuleReading(device_module=device_module, data=data) + + # By default no validation error is raised + reading.save() + + +@pytest.mark.django_db +def test_json_schema_invalid_data(): + schema = { + "$id": "https://example.com/person.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Person", + "type": "object", + "properties": { + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0, + } + }, + } + data = {"age": 21} + updated_data = {"age": "Some text"} + device_module = DeviceModuleFactory(schema=schema) + reading = DeviceModuleReading(device_module=device_module, data=data) + + # No error occurs with valid data + reading.save() + + # With invalid data it now throws a validation error + with pytest.raises(ValidationError): + reading.data = updated_data + reading.save() + + +@pytest.mark.django_db +def test_json_schema_update_with_invalid_data(): + schema = { + "$id": "https://example.com/person.schema.json", + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "Person", + "type": "object", + "properties": { + "age": { + "description": "Age in years which must be equal to or greater than zero.", + "type": "integer", + "minimum": 0, + } + }, + } + data = {"age": "Some text"} + device_module = DeviceModuleFactory(schema=schema) + reading = DeviceModuleReading(device_module=device_module, data=data) + + with pytest.raises(ValidationError): + reading.save() + + +@pytest.mark.django_db +def test_json_schema_invalid_schema(): + schema = {"type": 1234} + data = {"firstName": "John", "lastName": "Doe", "age": 21} + + device_module = DeviceModuleFactory(schema=schema) + reading = DeviceModuleReading(device_module=device_module, data=data) + + with pytest.raises(SchemaError): + reading.save() diff --git a/shedpi_hub_dashboard/tests/test_runs.py b/shedpi_hub_dashboard/tests/test_runs.py deleted file mode 100644 index 18695f0..0000000 --- a/shedpi_hub_dashboard/tests/test_runs.py +++ /dev/null @@ -1,2 +0,0 @@ -def test_runs(): - assert True diff --git a/shedpi_hub_dashboard/tests/utils/__init__.py b/shedpi_hub_dashboard/tests/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/shedpi_hub_dashboard/tests/utils/factories.py b/shedpi_hub_dashboard/tests/utils/factories.py new file mode 100644 index 0000000..eb544a0 --- /dev/null +++ b/shedpi_hub_dashboard/tests/utils/factories.py @@ -0,0 +1,35 @@ +from typing import ClassVar +from uuid import uuid4 + +import factory +from factory.django import DjangoModelFactory + +from shedpi_hub_dashboard.models import Device, DeviceModule, DeviceModuleReading + + +class DeviceFactory(DjangoModelFactory): + class Meta: + model = Device + + id = factory.LazyFunction(lambda: str(uuid4())) + name = factory.Faker("word") + location = factory.Faker("word") + + +class DeviceModuleFactory(DjangoModelFactory): + class Meta: + model = DeviceModule + + id = factory.LazyFunction(lambda: str(uuid4())) + device = factory.SubFactory(DeviceFactory) + name = factory.Faker("word") + location = factory.Faker("word") + schema: ClassVar[dict] = {} + + +class DeviceModuleReadingFactory(DjangoModelFactory): + class Meta: + model = DeviceModuleReading + + device_module = factory.SubFactory(DeviceModuleFactory) + data: ClassVar[dict] = {}