From dfa0d6be9255cc365d0121781ed30f0fe3d353d5 Mon Sep 17 00:00:00 2001 From: 0xc60f <0xc60f@duck.com> Date: Sat, 12 Oct 2024 12:15:08 -0700 Subject: [PATCH] Ported GroundObject model from Go (#52) * Ported GroundObject model from Go * Added endpoints for GroundObject * Almost done with tests, need some help * Urls import reordering * Finished endpoint fixes * Uncommitted Files Added * Uncommitted Files Added * lint: black * refactor: rename images to vision --------- Co-authored-by: 21chanas3 <40689649+21chanas3@users.noreply.github.com> --- .github/workflows/docker.yml | 7 +- .pre-commit-config.yaml | 4 - src/gcom/settings.py | 2 +- src/gcom/urls.py | 13 +- src/images/migrations/0001_initial.py | 41 ----- .../migrations/0002_alter_image_image.py | 18 --- src/images/models.py | 40 ----- src/{images => vision}/__init__.py | 0 src/{images => vision}/admin.py | 0 src/{images => vision}/apps.py | 4 +- src/vision/migrations/0001_initial.py | 121 ++++++++++++++ src/{images => vision}/migrations/__init__.py | 0 src/vision/models.py | 110 +++++++++++++ src/{images => vision}/serializers.py | 10 ++ src/{images => vision}/tests.py | 150 ++++++++++++++++-- src/vision/urls.py | 13 ++ src/{images => vision}/views.py | 12 +- 17 files changed, 411 insertions(+), 134 deletions(-) delete mode 100644 src/images/migrations/0001_initial.py delete mode 100644 src/images/migrations/0002_alter_image_image.py delete mode 100644 src/images/models.py rename src/{images => vision}/__init__.py (100%) rename src/{images => vision}/admin.py (100%) rename src/{images => vision}/apps.py (64%) create mode 100644 src/vision/migrations/0001_initial.py rename src/{images => vision}/migrations/__init__.py (100%) create mode 100644 src/vision/models.py rename src/{images => vision}/serializers.py (51%) rename src/{images => vision}/tests.py (56%) create mode 100644 src/vision/urls.py rename src/{images => vision}/views.py (51%) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 3f9767e..e354302 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -15,12 +15,12 @@ jobs: steps: - name: Set up QEMU uses: docker/setup-qemu-action@v3 - + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - + - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - name: Login to Docker Hub uses: docker/login-action@v3 @@ -34,4 +34,3 @@ jobs: platforms: linux/amd64,linux/arm64 push: ${{ github.event_name == 'push' }} tags: ubcuas/${{ github.event.repository.name }}:latest - diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e564cf9..79c3278 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,7 +18,3 @@ repos: hooks: - id: ruff args: [ --fix ] -- repo: https://github.com/asottile/reorder-python-imports - rev: v3.13.0 - hooks: - - id: reorder-python-imports diff --git a/src/gcom/settings.py b/src/gcom/settings.py index d138270..b139e1c 100644 --- a/src/gcom/settings.py +++ b/src/gcom/settings.py @@ -44,7 +44,7 @@ "rest_framework", "nav.apps.NavConfig", "drone.apps.DroneConfig", - "images.apps.ImagesConfig", + "vision.apps.VisionConfig", ] MIDDLEWARE = [ diff --git a/src/gcom/urls.py b/src/gcom/urls.py index 781301c..7e2e287 100644 --- a/src/gcom/urls.py +++ b/src/gcom/urls.py @@ -16,18 +16,16 @@ """ from django.contrib import admin -from django.urls import path, include -from rest_framework.routers import DefaultRouter from django.contrib.staticfiles.urls import staticfiles_urlpatterns -from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView - - +from django.urls import include +from django.urls import path +from drf_spectacular.views import SpectacularAPIView +from drf_spectacular.views import SpectacularSwaggerView from nav.views import WaypointViewset -from images.views import ImageViewset +from rest_framework.routers import DefaultRouter router = DefaultRouter() router.register(r"waypoint", WaypointViewset, basename="waypoint") -router.register(r"images", ImageViewset, basename="images") urlpatterns = [ # Swagger Docs @@ -42,6 +40,7 @@ # API path("api/", include(router.urls)), path("api/drone/", include("drone.urls")), + path("api/vision/", include("vision.urls")), ] urlpatterns += staticfiles_urlpatterns() diff --git a/src/images/migrations/0001_initial.py b/src/images/migrations/0001_initial.py deleted file mode 100644 index ac9305d..0000000 --- a/src/images/migrations/0001_initial.py +++ /dev/null @@ -1,41 +0,0 @@ -# Generated by Django 5.0.6 on 2024-09-29 02:09 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [] - - operations = [ - migrations.CreateModel( - name="Image", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, - primary_key=True, - serialize=False, - verbose_name="ID", - ), - ), - ("image", models.ImageField(upload_to="images/")), - ("title", models.CharField(max_length=100)), - ( - "image_type", - models.CharField( - choices=[("visible", "Visible"), ("thermal", "Thermal")], - default="visible", - max_length=20, - ), - ), - ("taken_at", models.DateTimeField()), - ("longitude", models.FloatField(null=True)), - ("latitude", models.FloatField(null=True)), - ("altitude", models.FloatField(null=True)), - ], - ), - ] diff --git a/src/images/migrations/0002_alter_image_image.py b/src/images/migrations/0002_alter_image_image.py deleted file mode 100644 index e82b3db..0000000 --- a/src/images/migrations/0002_alter_image_image.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.0.6 on 2024-09-29 02:32 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ("images", "0001_initial"), - ] - - operations = [ - migrations.AlterField( - model_name="image", - name="image", - field=models.ImageField(upload_to="files/"), - ), - ] diff --git a/src/images/models.py b/src/images/models.py deleted file mode 100644 index 97a0173..0000000 --- a/src/images/models.py +++ /dev/null @@ -1,40 +0,0 @@ -from django.db import models - - -# Create your models here. -class Image(models.Model): - """Represents an image taken by the drone. - - Args: - image (models.ImageField): The image file. - title (models.CharField): The title of the image. - image_type (models.CharField): The type of image. - taken_at (models.DateTimeField): The time the image was received. - longitude (models.FloatField): The longitude where the image was taken. - latitude (models.FloatField): The latitude where the image was taken. - altitude (models.FloatField): The altitude where the image was taken. - """ - - class ImageType(models.TextChoices): - """Represents the type of image. - - Options: - VISIBLE: A visible light image. - THERMAL: A thermal (IR) image. - """ - - VISIBLE = "visible", "Visible" - THERMAL = "thermal", "Thermal" - - image = models.ImageField(upload_to="files/", null=False) - title = models.CharField(max_length=100) - image_type = models.CharField( - max_length=20, choices=ImageType.choices, default=ImageType.VISIBLE - ) - taken_at = models.DateTimeField(null=False) - longitude = models.FloatField(null=True) - latitude = models.FloatField(null=True) - altitude = models.FloatField(null=True) - - def __str__(self): - return self.taken_at.strftime("%Y-%m-%d %H:%M:%S") diff --git a/src/images/__init__.py b/src/vision/__init__.py similarity index 100% rename from src/images/__init__.py rename to src/vision/__init__.py diff --git a/src/images/admin.py b/src/vision/admin.py similarity index 100% rename from src/images/admin.py rename to src/vision/admin.py diff --git a/src/images/apps.py b/src/vision/apps.py similarity index 64% rename from src/images/apps.py rename to src/vision/apps.py index 5f752ba..235d2e1 100644 --- a/src/images/apps.py +++ b/src/vision/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class ImagesConfig(AppConfig): +class VisionConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "images" + name = "vision" diff --git a/src/vision/migrations/0001_initial.py b/src/vision/migrations/0001_initial.py new file mode 100644 index 0000000..a1a32c7 --- /dev/null +++ b/src/vision/migrations/0001_initial.py @@ -0,0 +1,121 @@ +# Generated by Django 5.1.2 on 2024-10-12 14:39 + +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="GroundObject", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + unique=True, + ), + ), + ( + "object_type", + models.CharField( + choices=[("standard", "Standard"), ("emergent", "Emergent")], + default="standard", + max_length=10, + ), + ), + ("lat", models.FloatField()), + ("long", models.FloatField()), + ( + "shape", + models.CharField( + choices=[ + ("circle", "Circle"), + ("semicircle", "Semi-Circle"), + ("quartercircle", "Quarter-Circle"), + ("triangle", "Triangle"), + ("rectangle", "Rectangle"), + ("pentagon", "Pentagon"), + ("star", "Star"), + ("cross", "Cross"), + ], + default="circle", + max_length=15, + ), + ), + ( + "color", + models.CharField( + choices=[ + ("black", "Black"), + ("red", "Red"), + ("blue", "Blue"), + ("green", "Green"), + ("purple", "Purple"), + ("brown", "Brown"), + ("orange", "Orange"), + ], + default="black", + max_length=10, + ), + ), + ("text", models.CharField(max_length=100)), + ( + "text_color", + models.CharField( + choices=[ + ("black", "Black"), + ("red", "Red"), + ("blue", "Blue"), + ("green", "Green"), + ("purple", "Purple"), + ("brown", "Brown"), + ("orange", "Orange"), + ], + default="black", + max_length=10, + ), + ), + ], + options={ + "verbose_name": "Ground Object", + "verbose_name_plural": "Ground Objects", + }, + ), + migrations.CreateModel( + name="Image", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("image", models.ImageField(upload_to="files/")), + ("title", models.CharField(max_length=100)), + ( + "image_type", + models.CharField( + choices=[("visible", "Visible"), ("thermal", "Thermal")], + default="visible", + max_length=20, + ), + ), + ("taken_at", models.DateTimeField()), + ("longitude", models.FloatField(null=True)), + ("latitude", models.FloatField(null=True)), + ("altitude", models.FloatField(null=True)), + ], + ), + ] diff --git a/src/images/migrations/__init__.py b/src/vision/migrations/__init__.py similarity index 100% rename from src/images/migrations/__init__.py rename to src/vision/migrations/__init__.py diff --git a/src/vision/models.py b/src/vision/models.py new file mode 100644 index 0000000..44d9279 --- /dev/null +++ b/src/vision/models.py @@ -0,0 +1,110 @@ +import uuid +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +# Create your models here. +class Image(models.Model): + """Represents an image taken by the drone. + + Args: + image (models.ImageField): The image file. + title (models.CharField): The title of the image. + image_type (models.CharField): The type of image. + taken_at (models.DateTimeField): The time the image was received. + longitude (models.FloatField): The longitude where the image was taken. + latitude (models.FloatField): The latitude where the image was taken. + altitude (models.FloatField): The altitude where the image was taken. + """ + + class ImageType(models.TextChoices): + """Represents the type of image. + + Options: + VISIBLE: A visible light image. + THERMAL: A thermal (IR) image. + """ + + VISIBLE = "visible", "Visible" + THERMAL = "thermal", "Thermal" + + image = models.ImageField(upload_to="files/", null=False) + title = models.CharField(max_length=100) + image_type = models.CharField( + max_length=20, choices=ImageType.choices, default=ImageType.VISIBLE + ) + taken_at = models.DateTimeField(null=False) + longitude = models.FloatField(null=True) + latitude = models.FloatField(null=True) + altitude = models.FloatField(null=True) + + def __str__(self): + return self.taken_at.strftime("%Y-%m-%d %H:%M:%S") + + +class GroundObject(models.Model): + """Represents a ground object, either emergent or standard, detected by the system. + + Attributes: + id (UUID): Primary key, unique identifier for each ground object + object_type (ObjectType): Type of the ground object (standard or emergent) + lat (float): Latitude of the ground object + long (float): Longitude of the ground object + shape (Shape): Shape of the ground object + color (Color): Color of the ground object + text (str): Text displayed on the ground object + text_color (Color): Color of the text displayed on the ground object + """ + + class ObjectType(models.TextChoices): + STANDARD = "standard", _("Standard") + EMERGENT = "emergent", _("Emergent") + + class Color(models.TextChoices): + BLACK = "black", _("Black") + RED = "red", _("Red") + BLUE = "blue", _("Blue") + GREEN = "green", _("Green") + PURPLE = "purple", _("Purple") + BROWN = "brown", _("Brown") + ORANGE = "orange", _("Orange") + + class Shape(models.TextChoices): + CIRCLE = "circle", _("Circle") + SEMI_CIRCLE = "semicircle", _("Semi-Circle") + QUARTER_CIRCLE = "quartercircle", _("Quarter-Circle") + TRIANGLE = "triangle", _("Triangle") + RECTANGLE = "rectangle", _("Rectangle") + PENTAGON = "pentagon", _("Pentagon") + STAR = "star", _("Star") + CROSS = "cross", _("Cross") + + id = models.UUIDField( + primary_key=True, editable=False, unique=True, default=uuid.uuid4 + ) + object_type = models.CharField( + max_length=10, choices=ObjectType.choices, default=ObjectType.STANDARD + ) + lat = models.FloatField(null=False) + long = models.FloatField(null=False) + shape = models.CharField( + null=False, max_length=15, choices=Shape.choices, default=Shape.CIRCLE + ) + color = models.CharField( + null=False, max_length=10, choices=Color.choices, default=Color.BLACK + ) + text = models.CharField(max_length=100, null=False, blank=False) + text_color = models.CharField( + max_length=10, + choices=Color.choices, + default=Color.BLACK, + null=False, + blank=False, + ) + + def __str__(self): + return self.text + + class Meta: + verbose_name = _("Ground Object") + verbose_name_plural = _("Ground Objects") diff --git a/src/images/serializers.py b/src/vision/serializers.py similarity index 51% rename from src/images/serializers.py rename to src/vision/serializers.py index a85e4fd..b06ce15 100644 --- a/src/images/serializers.py +++ b/src/vision/serializers.py @@ -1,4 +1,6 @@ from rest_framework import serializers + +from .models import GroundObject from .models import Image @@ -8,3 +10,11 @@ class ImageSerializer(serializers.ModelSerializer): class Meta: model = Image fields = "__all__" + + +class GroundObjectSerializer(serializers.ModelSerializer): + """Serializer to convert GroundObject objects to JSON""" + + class Meta: + model = GroundObject + fields = "__all__" diff --git a/src/images/tests.py b/src/vision/tests.py similarity index 56% rename from src/images/tests.py rename to src/vision/tests.py index 6005934..4148fd0 100644 --- a/src/images/tests.py +++ b/src/vision/tests.py @@ -1,14 +1,15 @@ -from io import BytesIO import json -from django.test import TestCase -from .models import Image -from django.db.utils import IntegrityError -from rest_framework.test import APITestCase +import shutil +from io import BytesIO from django.core.files.uploadedfile import SimpleUploadedFile -from PIL import Image as PILImage +from django.db.utils import IntegrityError from django.test import override_settings -import shutil - +from django.test import TestCase +from PIL import Image as PILImage +from rest_framework import status +from rest_framework.test import APITestCase +from .models import GroundObject +from .models import Image TEST_DIR = "test_files" @@ -85,7 +86,7 @@ def setUp(self): ) def test_get_images(self): - response = self.client.get("/api/images/", format="json") + response = self.client.get("/api/vision/image/", format="json") self.assertEqual(response.status_code, 200) self.assertEqual(response.data[0]["title"], "Test Image") self.assertEqual(response.data[0]["image_type"], "visible") @@ -95,7 +96,7 @@ def test_post_image(self): image_count = Image.objects.count() with self.generate_image_file() as test_image: response = self.client.post( - "/api/images/", + "/api/vision/image/", { "image": test_image, "title": "New Image", @@ -119,7 +120,7 @@ def test_post_image_invalid_request(self): image_count = Image.objects.count() with self.generate_image_file() as test_image: response = self.client.post( - "/api/images/", + "/api/vision/image/", {"image": test_image, "title": "New Image", "image_type": "thermal"}, format="multipart", ) @@ -129,7 +130,7 @@ def test_post_image_invalid_request(self): def test_post_image_file_invalid(self): image_count = Image.objects.count() response = self.client.post( - "/api/images/", + "/api/vision/image/", { "image": b"invalid.jpg", "title": "New Image", @@ -142,14 +143,14 @@ def test_post_image_file_invalid(self): self.assertEqual(Image.objects.count(), image_count) def test_get_image(self): - response = self.client.get("/api/images/1/", format="json") + response = self.client.get("/api/vision/image/1/", format="json") self.assertEqual(response.status_code, 200) self.assertEqual(response.data["title"], "Test Image") self.assertEqual(response.data["image_type"], "visible") self.assertEqual(response.data["taken_at"], "2021-01-01T12:00:00Z") def test_get_all_images(self): - response = self.client.get("/api/images/", format="json") + response = self.client.get("/api/vision/image/", format="json") self.assertEqual(response.status_code, 200) self.assertEqual(response.data[0]["title"], "Test Image") self.assertEqual(response.data[0]["image_type"], "visible") @@ -164,7 +165,7 @@ def test_delete_image(self): image_type="visible", taken_at="2021-01-01 12:00:00", ) - response = self.client.delete(f"/api/images/{img.id}/", format="json") + response = self.client.delete(f"/api/vision/image/{img.id}/", format="json") self.assertEqual(response.status_code, 204) self.assertEqual(Image.objects.count(), image_count) @@ -178,7 +179,7 @@ def test_edit_image(self): ) response = self.client.patch( - f"/api/images/{img.id}/", + f"/api/vision/image/{img.id}/", { "title": "New Image", "image_type": "thermal", @@ -196,3 +197,120 @@ def test_edit_image(self): def tearDown(self): shutil.rmtree(TEST_DIR) + + +class GroundObjectModelTests(TestCase): + def setUp(self): + GroundObject.objects.create( + object_type=GroundObject.ObjectType.STANDARD, + lat=12.3456, + long=78.9101, + shape=GroundObject.Shape.CIRCLE, + color=GroundObject.Color.RED, + text="Sample Text", + text_color=GroundObject.Color.BLACK, + ) + + def test_ground_object_creation(self): + ground_object = GroundObject.objects.get(text="Sample Text") + self.assertEqual(ground_object.object_type, GroundObject.ObjectType.STANDARD) + self.assertEqual(ground_object.lat, 12.3456) + self.assertEqual(ground_object.long, 78.9101) + self.assertEqual(ground_object.shape, GroundObject.Shape.CIRCLE) + self.assertEqual(ground_object.color, GroundObject.Color.RED) + self.assertEqual(ground_object.text, "Sample Text") + self.assertEqual(ground_object.text_color, GroundObject.Color.BLACK) + + def test_cannot_create_without_lat_long(self): + with self.assertRaises(IntegrityError): + GroundObject.objects.create( + object_type=GroundObject.ObjectType.STANDARD, + shape=GroundObject.Shape.CIRCLE, + color=GroundObject.Color.RED, + text="Sample Text", + text_color=GroundObject.Color.BLACK, + ) + + def test_representation(self): + ground_object = GroundObject.objects.get(text="Sample Text") + self.assertEqual(str(ground_object), "Sample Text") + + +class GroundObjectEndpointTests(APITestCase): + def setUp(self): + self.ground_object = GroundObject.objects.create( + object_type=GroundObject.ObjectType.STANDARD, + lat=12.3456, + long=78.9101, + shape=GroundObject.Shape.CIRCLE, + color=GroundObject.Color.RED, + text="Sample Text", + text_color=GroundObject.Color.BLACK, + ) + + def test_get_ground_object(self): + response = self.client.get( + f"/api/vision/groundobject/{self.ground_object.id}/", format="json" + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["text"], "Sample Text") + self.assertEqual(response.data["lat"], 12.3456) + self.assertEqual(response.data["long"], 78.9101) + + def test_get_all_ground_objects(self): + response = self.client.get("/api/vision/groundobject/", format="json") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data[0]["text"], "Sample Text") + + def test_create_ground_object(self): + ground_object_count = GroundObject.objects.count() + response = self.client.post( + "/api/vision/groundobject/", + { + "object_type": GroundObject.ObjectType.STANDARD, + "lat": 10.1234, + "long": 20.5678, + "shape": GroundObject.Shape.RECTANGLE, + "color": GroundObject.Color.BLUE, + "text": "Sample Text", + "text_color": GroundObject.Color.RED, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(GroundObject.objects.count(), ground_object_count + 1) + + def test_delete_ground_object(self): + response = self.client.delete( + f"/api/vision/groundobject/{self.ground_object.id}/", format="json" + ) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(GroundObject.objects.count(), 0) + + def test_edit_ground_object(self): + response = self.client.patch( + f"/api/vision/groundobject/{self.ground_object.id}/", + { + "text": "Updated Text", + "color": GroundObject.Color.GREEN, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + updated_object = GroundObject.objects.get(id=self.ground_object.id) + self.assertEqual(updated_object.text, "Updated Text") + self.assertEqual(updated_object.color, GroundObject.Color.GREEN) + + def test_create_ground_object_invalid_request(self): + ground_object_count = GroundObject.objects.count() + response = self.client.post( + "/api/vision/groundobject/", + { + "object_type": GroundObject.ObjectType.STANDARD, + "shape": GroundObject.Shape.RECTANGLE, + "color": GroundObject.Color.BLUE, + }, + format="json", + ) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(GroundObject.objects.count(), ground_object_count) diff --git a/src/vision/urls.py b/src/vision/urls.py new file mode 100644 index 0000000..3f280ac --- /dev/null +++ b/src/vision/urls.py @@ -0,0 +1,13 @@ +from django.urls import include +from django.urls import path +from rest_framework.routers import DefaultRouter +from .views import GroundObjectViewset +from .views import ImageViewset + +router = DefaultRouter() +router.register(r"image", ImageViewset, basename="image") +router.register(r"groundobject", GroundObjectViewset, basename="groundobject") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/src/images/views.py b/src/vision/views.py similarity index 51% rename from src/images/views.py rename to src/vision/views.py index f13222c..abdcbfd 100644 --- a/src/images/views.py +++ b/src/vision/views.py @@ -1,6 +1,9 @@ +from rest_framework import viewsets + +from .models import GroundObject from .models import Image +from .serializers import GroundObjectSerializer from .serializers import ImageSerializer -from rest_framework import viewsets # Create your views here. @@ -9,3 +12,10 @@ class ImageViewset(viewsets.ModelViewSet): queryset = Image.objects.all() serializer_class = ImageSerializer + + +class GroundObjectViewset(viewsets.ModelViewSet): + """Viewset for CRUD operations on GroundObjects""" + + queryset = GroundObject.objects.all() + serializer_class = GroundObjectSerializer