diff --git a/apps/device/constants.py b/apps/device/constants.py index fd52b4f..d8bc7b6 100644 --- a/apps/device/constants.py +++ b/apps/device/constants.py @@ -1,5 +1,6 @@ from dataclasses import dataclass from datetime import datetime +from enum import Enum from django.db import models @@ -22,3 +23,8 @@ class LocationPoint: class DeviceStatus(models.TextChoices): ACTIVE = "active" IN_INVENTORY = "in_inventory" + + +class DeviceRelation(str, Enum): + LORAWAN = "lorawan_device" + API = "api_device" diff --git a/apps/device/migrations/0020_move_location_to_device.py b/apps/device/migrations/0020_move_location_to_device.py index 9a2f638..990bcd1 100644 --- a/apps/device/migrations/0020_move_location_to_device.py +++ b/apps/device/migrations/0020_move_location_to_device.py @@ -1,5 +1,3 @@ -# Generated by Codex on 2026-08-07 - from django.db import migrations, models diff --git a/apps/device/migrations/0021_move_network_server_claim_code_and_add_api_device.py b/apps/device/migrations/0021_move_network_server_claim_code_and_add_api_device.py new file mode 100644 index 0000000..a1a2ad7 --- /dev/null +++ b/apps/device/migrations/0021_move_network_server_claim_code_and_add_api_device.py @@ -0,0 +1,161 @@ +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +def copy_device_fields_forward(apps, schema_editor): + Device = apps.get_model("device", "Device") + LorawanDevice = apps.get_model("device", "LorawanDevice") + + for lorawan_device in LorawanDevice.objects.values( + "id", + "device_id", + "claim_code", + "device__network_server_id", + ).iterator(): + network_server_id = lorawan_device["device__network_server_id"] + if network_server_id: + LorawanDevice.objects.filter(id=lorawan_device["id"]).update( + network_server_id=network_server_id + ) + + claim_code = lorawan_device["claim_code"] + if claim_code: + Device.objects.filter(id=lorawan_device["device_id"]).update( + claim_code=claim_code + ) + + +def copy_device_fields_backward(apps, schema_editor): + Device = apps.get_model("device", "Device") + LorawanDevice = apps.get_model("device", "LorawanDevice") + + for lorawan_device in LorawanDevice.objects.values( + "id", + "device_id", + "network_server_id", + "device__claim_code", + ).iterator(): + network_server_id = lorawan_device["network_server_id"] + if network_server_id: + Device.objects.filter(id=lorawan_device["device_id"]).update( + network_server_id=network_server_id + ) + + claim_code = lorawan_device["device__claim_code"] + if claim_code: + LorawanDevice.objects.filter(id=lorawan_device["id"]).update( + claim_code=claim_code + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("device", "0020_move_location_to_device"), + ("network_server", "0004_alter_networkserver_logo"), + ] + + operations = [ + migrations.AddField( + model_name="device", + name="claim_code", + field=models.CharField( + blank=True, + max_length=100, + null=True, + unique=True, + ), + ), + migrations.AddField( + model_name="lorawandevice", + name="network_server", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="lorawan_devices", + to="network_server.networkserver", + ), + ), + migrations.CreateModel( + name="APIDevice", + fields=[ + ( + "created_at", + models.DateTimeField(auto_now_add=True), + ), + ( + "updated_at", + models.DateTimeField(auto_now=True), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "serial_number", + models.CharField(max_length=100, unique=True), + ), + ( + "device", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="api_device", + to="device.device", + ), + ), + ], + options={ + "abstract": False, + }, + ), + migrations.RunPython(copy_device_fields_forward, copy_device_fields_backward), + migrations.AlterField( + model_name="device", + name="id", + field=models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + migrations.AlterField( + model_name="lorawandevice", + name="id", + field=models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + migrations.AlterField( + model_name="spacedevice", + name="id", + field=models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + migrations.RemoveField( + model_name="device", + name="network_server", + ), + migrations.RemoveField( + model_name="lorawandevice", + name="claim_code", + ), + ] diff --git a/apps/device/models.py b/apps/device/models.py index af1bf53..ef99cdd 100644 --- a/apps/device/models.py +++ b/apps/device/models.py @@ -11,15 +11,8 @@ class Device(BaseModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - network_server = models.ForeignKey( - NetworkServer, - related_name="devices", - on_delete=models.CASCADE, - blank=True, - null=True, - ) device_model = models.UUIDField(null=True, blank=True) + claim_code = models.CharField(max_length=100, null=True, blank=True, unique=True) status = models.CharField( choices=DeviceStatus.choices, default=DeviceStatus.IN_INVENTORY ) @@ -35,18 +28,29 @@ class Device(BaseModel): class LorawanDevice(BaseModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) device = models.OneToOneField( Device, related_name="lorawan_device", on_delete=models.CASCADE ) dev_eui = models.CharField(max_length=16, unique=True) join_eui = models.CharField(max_length=16, null=True, blank=True) app_key = models.CharField(max_length=32, null=True, blank=True) - claim_code = models.CharField(max_length=100, null=True, blank=True, unique=True) + network_server = models.ForeignKey( + NetworkServer, + related_name="lorawan_devices", + on_delete=models.CASCADE, + blank=True, + null=True, + ) + + +class APIDevice(BaseModel): + device = models.OneToOneField( + Device, related_name="api_device", on_delete=models.CASCADE + ) + serial_number = models.CharField(max_length=100, unique=True) class SpaceDevice(BaseModel): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) name = models.CharField(max_length=255) description = models.TextField(null=True, blank=True) space = models.ForeignKey( diff --git a/apps/device/serializers.py b/apps/device/serializers.py index b17deed..30efc33 100644 --- a/apps/device/serializers.py +++ b/apps/device/serializers.py @@ -12,12 +12,18 @@ FloorSerializer, ) from apps.device.constants import DeviceStatus -from apps.device.models import Device, LorawanDevice, SpaceDevice, Trip +from apps.device.models import APIDevice, Device, LorawanDevice, SpaceDevice, Trip from apps.device.services.entity_properties_context import ( _resolve_entity_properties_from_context, ) +from apps.device.services.nested_device_handlers import ( + get_nested_device_handlers, + get_relation, + get_relations, +) from apps.facility.models import Facility from apps.facility.serializers import FacilitySerializer +from apps.network_server.models import NetworkServer from apps.network_server.serializers import NetworkServerSerializer from apps.placement.models import Position from apps.placement.serializers import PositionSerializer @@ -29,44 +35,71 @@ class LorawanDeviceSerializer(serializers.ModelSerializer): dev_eui = HexCharField(length=16, unique=True) join_eui = HexCharField(length=16) app_key = HexCharField(length=32) + network_server = serializers.PrimaryKeyRelatedField( + queryset=NetworkServer.objects.all() + ) class Meta: model = LorawanDevice - fields = ["join_eui", "dev_eui", "app_key", "claim_code"] + fields = ["join_eui", "dev_eui", "app_key", "network_server"] + + +class ReadLorawanDeviceSerializer(LorawanDeviceSerializer): + network_server = NetworkServerSerializer(read_only=True) + + +class APIDeviceSerializer(serializers.ModelSerializer): + class Meta: + model = APIDevice + fields = ["serial_number"] class MultiDeviceSerializer(serializers.ListSerializer): def to_internal_value(self, data): + handlers = get_nested_device_handlers() + identifiers_by_relation = {relation: set() for relation in handlers} + resolved_items = [] + + for item in data: + relation = get_relation(item, handlers) + + if relation is None: + resolved_items.append((item, None, None)) + continue + + handler = handlers[relation] + identifier = handler.get_identifier(item) + resolved_items.append((item, relation, identifier)) + + if identifier: + identifiers_by_relation[relation].add(identifier) + valid_items = [] duplicated = [] validation_error = [] - request_dev_euis = set() - - existing_dev_euis = set( - LorawanDevice.objects.filter( - dev_eui__in=[ - item.get("lorawan_device", {}).get("dev_eui") for item in data - ] - ).values_list("dev_eui", flat=True) - ) - - for item in data: - lorawan_device = item.get("lorawan_device") or {} - dev_eui = lorawan_device.get("dev_eui") + existing_identifiers = { + relation: handlers[relation].get_existing_identifiers(identifiers) + if identifiers + else set() + for relation, identifiers in identifiers_by_relation.items() + } + request_identifiers = {relation: set() for relation in handlers} - if not dev_eui: - validation_error.append(dev_eui) + for item, relation, identifier in resolved_items: + if relation is None or not identifier: + validation_error.append(None) continue - if dev_eui in request_dev_euis: - duplicated.append(dev_eui) + if identifier in request_identifiers[relation]: + duplicated.append(identifier) continue - request_dev_euis.add(dev_eui) - if dev_eui in existing_dev_euis: - duplicated.append(dev_eui) + if identifier in existing_identifiers[relation]: + duplicated.append(identifier) continue + request_identifiers[relation].add(identifier) + serializer = self.child.__class__( data=item, context=self.context, @@ -76,7 +109,7 @@ def to_internal_value(self, data): valid_items.append(serializer.validated_data) continue - validation_error.append(dev_eui) + validation_error.append(identifier) self._total_failed = len(duplicated) + len(validation_error) self._failed_data = { @@ -88,18 +121,27 @@ def to_internal_value(self, data): @transaction.atomic def create(self, validated_data): + handlers = get_nested_device_handlers() device_objs = [] - lorawan_objs = [] + nested_objs = {relation: [] for relation in handlers} for item in validated_data: - lorawan_data = item.pop("lorawan_device", None) + relation = get_relation(item, handlers) + handler = handlers[relation] + nested_data = handler.pop_data(item) + device_obj = Device(**item) - lorawan_obj = LorawanDevice(device=device_obj, **lorawan_data) device_objs.append(device_obj) - lorawan_objs.append(lorawan_obj) + + if nested_data: + nested_objs[relation].append( + handler.build_instance(device_obj, nested_data) + ) Device.objects.bulk_create(device_objs) - LorawanDevice.objects.bulk_create(lorawan_objs) + for relation, objects in nested_objs.items(): + if objects: + handlers[relation].model_class.objects.bulk_create(objects) return device_objs @@ -131,16 +173,18 @@ class Meta: class DeviceSerializer(serializers.ModelSerializer): lorawan_device = LorawanDeviceSerializer(many=False, required=False) + api_device = APIDeviceSerializer(many=False, required=False) location = LocationSerializer(required=False, allow_null=True) class Meta: model = Device fields = [ "id", - "network_server", "device_model", + "claim_code", "status", "lorawan_device", + "api_device", "is_published", "is_deactivated", "cells", @@ -169,60 +213,61 @@ def to_representation(self, instance): data["device_profile"] = device_profile return data + def validate(self, attrs): + attrs = super().validate(attrs) + handlers = get_nested_device_handlers() + relations = get_relations(attrs, handlers) + + if len(relations) > 1: + raise serializers.ValidationError("Provide only one device type.") + if self.instance is None and not relations: + raise serializers.ValidationError("Provide exactly one device type.") + + return attrs + def create(self, validated_data): - lorawan_data = validated_data.pop("lorawan_device", None) - try: - device = Device.objects.create(**validated_data) - logger.info(f"Device created successfully with ID: {device.id}") + handler = self._get_input_handler(validated_data) + nested_data = handler.pop_data(validated_data) - if lorawan_data: - LorawanDevice.objects.create(device=device, **lorawan_data) - logger.info(f"LoRaWAN device created for device {device.id}") + try: + with transaction.atomic(): + device = Device.objects.create(**validated_data) + handler.create(device, nested_data) + logger.info(f"{handler.label} created for device {device.id}") + logger.info(f"Device created successfully with ID: {device.id}") return device except Exception as e: logger.error(f"Failed to create device: {str(e)}", exc_info=True) raise def update(self, instance, validated_data): - lorawan_data = validated_data.pop("lorawan_device", None) + handler = self._get_input_handler(validated_data) + nested_data = handler.pop_data(validated_data) if handler else None try: - for attr, value in validated_data.items(): - setattr(instance, attr, value) - instance.save() - logger.info(f"Device {instance.id} updated successfully") - except Exception as e: - logger.error( - f"Failed to update device {instance.id}: {str(e)}", exc_info=True - ) + with transaction.atomic(): + for attr, value in validated_data.items(): + setattr(instance, attr, value) + instance.save() + if handler: + handler.update(instance, nested_data) + logger.info("Device %s updated successfully", instance.id) + except Exception: + logger.exception("Failed to update device %s", instance.id) raise - - if lorawan_data: - try: - lorawan_instance = getattr(instance, "lorawan_device", None) - if lorawan_instance: - lorawan_serializer = LorawanDeviceSerializer( - instance=lorawan_instance, data=lorawan_data, partial=True - ) - lorawan_serializer.is_valid(raise_exception=True) - lorawan_serializer.save() - logger.info(f"LoRaWAN device updated for device {instance.id}") - else: - LorawanDevice.objects.create(device=instance, **lorawan_data) - logger.info(f"New LoRaWAN device created for device {instance.id}") - except Exception as e: - logger.error( - f"Failed to update LoRaWAN device for {instance.id}: {str(e)}", - exc_info=True, - ) - raise - return instance + def _get_input_handler(self, validated_data): + handlers_by_relation = get_nested_device_handlers() + relation = get_relation(validated_data, handlers_by_relation) + if not relation: + return None + return handlers_by_relation[relation] + class GetDeviceSerializer(DeviceSerializer): - network_server = NetworkServerSerializer(read_only=True) + lorawan_device = ReadLorawanDeviceSerializer(read_only=True) class Meta(DeviceSerializer.Meta): model = Device diff --git a/apps/device/services/nested_device_handlers.py b/apps/device/services/nested_device_handlers.py new file mode 100644 index 0000000..c9d6c88 --- /dev/null +++ b/apps/device/services/nested_device_handlers.py @@ -0,0 +1,101 @@ +from apps.device.constants import DeviceRelation + + +class NestedDeviceHandler: + relation = None + serializer_class = None + model_class = None + label = None + identifier_field = None + + def pop_data(self, validated_data): + return validated_data.pop(self.relation, None) + + def raw_data(self, item): + return item.get(self.relation) or {} + + def get_identifier(self, item): + if not self.identifier_field: + return None + return self.raw_data(item).get(self.identifier_field) + + def get_existing_identifiers(self, values): + if not self.identifier_field: + return set() + return set( + self.model_class.objects.filter( + **{f"{self.identifier_field}__in": values} + ).values_list(self.identifier_field, flat=True) + ) + + def build_instance(self, device, data): + return self.model_class(device=device, **data) + + def create(self, device, data): + if data: + self.model_class.objects.create(device=device, **data) + + def update(self, device, data): + if not data: + return None + + nested_instance = getattr(device, self.relation, None) + if nested_instance: + serializer = self.serializer_class( + instance=nested_instance, + data=data, + partial=True, + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return "updated" + + self.create(device, data) + return "created" + + +class LorawanDeviceHandler(NestedDeviceHandler): + relation = DeviceRelation.LORAWAN + label = "LoRaWAN device" + identifier_field = "dev_eui" + + def __init__(self, serializer_class, model_class): + self.serializer_class = serializer_class + self.model_class = model_class + + +class APIDeviceHandler(NestedDeviceHandler): + relation = DeviceRelation.API + label = "API device" + identifier_field = "serial_number" + + def __init__(self, serializer_class, model_class): + self.serializer_class = serializer_class + self.model_class = model_class + + +def get_nested_device_handlers(): + from apps.device.models import APIDevice, LorawanDevice + from apps.device.serializers import APIDeviceSerializer, LorawanDeviceSerializer + + handlers = [ + LorawanDeviceHandler(LorawanDeviceSerializer, LorawanDevice), + APIDeviceHandler(APIDeviceSerializer, APIDevice), + ] + return {handler.relation: handler for handler in handlers} + + +def get_relation(item, handlers): + relations = get_relations(item, handlers) + + if len(relations) != 1: + return None + + return next(iter(relations)) + +# Get the intersection of keys in item and handlers +# And then get the relation names from the matched keys +# Complexity: O(min(len(item), len(handlers))) +def get_relations(item, handlers): + matched_relations = item.keys() & handlers.keys() + return {handlers[relation].relation for relation in matched_relations} diff --git a/apps/device/views.py b/apps/device/views.py index a144cc0..41477c6 100644 --- a/apps/device/views.py +++ b/apps/device/views.py @@ -52,7 +52,11 @@ class DeviceViewSet( QuotaMixin, viewsets.ModelViewSet, ): - queryset = Device.objects.select_related("lorawan_device", "network_server").all() + queryset = Device.objects.select_related( + "lorawan_device", + "lorawan_device__network_server", + "api_device", + ).all() pagination_class = BasePagination filter_backends = [OrderingFilter, SearchFilter, DjangoFilterBackend] ordering_fields = ["created_at"] @@ -111,6 +115,8 @@ class ListCreateSpaceDeviceViewSet(SpaceListCreateAPIView): queryset = SpaceDevice.objects.select_related( "device", "device__lorawan_device", + "device__lorawan_device__network_server", + "device__api_device", "floor", "area", "facility", @@ -172,7 +178,7 @@ def list(self, request, *args, **kwargs): class FindDeviceByCodeView(DeactivationMixin, views.APIView): def get(self, request, *args, **kwargs): claim_code = kwargs.get("claim_code") - device = Device.objects.filter(lorawan_device__claim_code=claim_code).first() + device = Device.objects.filter(claim_code=claim_code).first() if not device: return Response( {"result": "The device not found in the organization!"}, @@ -330,9 +336,10 @@ def list(self, request, *args, **kwargs): class DeviceLookupView(UseTenantFromRequestMixin, generics.RetrieveAPIView): swagger_schema = None serializer_class = FormatDeviceSerializer - queryset = Device.objects.select_related("lorawan_device").prefetch_related( - "space_devices" - ) + queryset = Device.objects.select_related( + "lorawan_device", + "api_device", + ).prefetch_related("space_devices") def get_queryset(self): qs = super().get_queryset() @@ -371,6 +378,8 @@ class RetrieveSpaceDeviceView(DeactivationMixin, generics.RetrieveAPIView): queryset = SpaceDevice.objects.select_related( "device", "device__lorawan_device", + "device__lorawan_device__network_server", + "device__api_device", "floor", "area", "facility", @@ -406,7 +415,7 @@ class RetrievePublicSpaceDeviceView(DeactivationMixin, generics.RetrieveAPIView) lookup_field = "id" def get_queryset(self): - return Device.objects.select_related("lorawan_device").filter( + return Device.objects.select_related("lorawan_device", "api_device").filter( is_published=True, space_devices__isnull=True, )