From ddeeddae0bccc42905491a9dfa5b5de4bbc6c518 Mon Sep 17 00:00:00 2001 From: Wille Marcel Date: Fri, 29 Aug 2025 13:58:05 -0300 Subject: [PATCH] Add VectorDataset and VectorItem models & views --- Dockerfile | 25 +++--- docker-compose.yml | 20 +++++ requirements.txt | 9 ++- vbos/config/common.py | 18 ++++- vbos/config/local.py | 3 + vbos/datasets/__init__.py | 0 vbos/datasets/admin.py | 12 +++ vbos/datasets/migrations/0001_initial.py | 64 +++++++++++++++ vbos/datasets/migrations/__init__.py | 0 vbos/datasets/models.py | 25 ++++++ vbos/datasets/pagination.py | 7 ++ vbos/datasets/serializers.py | 32 ++++++++ vbos/datasets/test/__init__.py | 0 vbos/datasets/test/test_views.py | 78 +++++++++++++++++++ vbos/datasets/urls.py | 19 +++++ vbos/datasets/views.py | 30 +++++++ vbos/urls.py | 29 +++++-- .../migrations/0003_alter_user_first_name.py | 20 +++++ vbos/users/test/test_views.py | 4 +- vbos/users/urls.py | 13 ++++ 20 files changed, 380 insertions(+), 28 deletions(-) create mode 100644 vbos/datasets/__init__.py create mode 100644 vbos/datasets/admin.py create mode 100644 vbos/datasets/migrations/0001_initial.py create mode 100644 vbos/datasets/migrations/__init__.py create mode 100644 vbos/datasets/models.py create mode 100644 vbos/datasets/pagination.py create mode 100644 vbos/datasets/serializers.py create mode 100644 vbos/datasets/test/__init__.py create mode 100644 vbos/datasets/test/test_views.py create mode 100644 vbos/datasets/urls.py create mode 100644 vbos/datasets/views.py create mode 100644 vbos/users/migrations/0003_alter_user_first_name.py create mode 100644 vbos/users/urls.py diff --git a/Dockerfile b/Dockerfile index 0ea9ba0..155b58e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,16 @@ -FROM python:3.13-slim as base -FROM base as builder +FROM ubuntu:24.04 +ARG DEBIAN_FRONTEND=noninteractive -# Allows docker to cache installed dependencies between builds -RUN apt-get update && apt-get -y install libpq-dev gcc +RUN apt-get update -qq -y \ + && apt-get install -y binutils libproj-dev python3-gdal libgeos-dev libyaml-dev python3-pip \ + && apt-get clean \ COPY ./requirements.txt requirements.txt -RUN pip3 install --no-cache-dir --target=packages -r requirements.txt +RUN pip install --no-cache-dir --target=packages -r requirements.txt -FROM base as runtime -COPY --from=builder packages /usr/lib/python3.12/site-packages -ENV PYTHONPATH=/usr/lib/python3.12/site-packages - -# Security Context -RUN useradd -m nonroot -USER nonroot - -COPY . code -WORKDIR code +COPY . /app +RUN useradd django +RUN chown -R django:django /app +WORKDIR /app EXPOSE 8000 # Run the production server diff --git a/docker-compose.yml b/docker-compose.yml index eb49fcd..613d0ac 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -36,3 +36,23 @@ services: - ./:/code ports: - "8001:8001" + titiler: + image: ghcr.io/developmentseed/titiler:latest + container_name: titiler + platform: linux/amd64 + environment: + CPL_TMPDIR: /tmp + GDAL_CACHEMAX: 75% + VSI_CACHE: TRUE + VSI_CACHE_SIZE: 1073741824 + GDAL_DISABLE_READDIR_ON_OPEN: EMPTY_DIR + GDAL_HTTP_MERGE_CONSECUTIVE_RANGES: YES + GDAL_HTTP_MULTIPLEX: YES + GDAL_HTTP_VERSION: 2 + PYTHONWARNINGS: ignore + WEB_CONCURRENCY: 4 + ports: + - "8002:8000" + volumes: + - ./data:/data # Optional: mount local directory with your raster files + restart: unless-stopped diff --git a/requirements.txt b/requirements.txt index 0219e93..fa62e17 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ # Core -Django==5.1.7 +Django==5.2.5 django-configurations==2.5.1 gunicorn==23.0.0 setuptools==78.1.1 @@ -13,8 +13,11 @@ django-model-utils==5.0.0 django_unique_upload==0.2.1 # Rest apis -djangorestframework==3.15.2 +djangorestframework==3.16.1 +djangorestframework-gis==1.2.0 django-filter==24.3 +drf_spectacular==0.28.0 +django-cors-headers==4.7.0 # Developer Tools ipdb==0.13.13 @@ -24,6 +27,6 @@ flake8==7.1.1 # Testing mock==5.1.0 -factory-boy==3.3.1 +factory-boy==3.3.3 pytest-django==4.9.0 coverage==7.6.9 diff --git a/vbos/config/common.py b/vbos/config/common.py index 83ede71..3e1bb49 100755 --- a/vbos/config/common.py +++ b/vbos/config/common.py @@ -10,6 +10,7 @@ class Common(Configuration): INSTALLED_APPS = ( "django.contrib.admin", + "django.contrib.gis", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.sessions", @@ -18,15 +19,20 @@ class Common(Configuration): # Third party apps "rest_framework", # utilities for rest apis "rest_framework.authtoken", # token authentication + "rest_framework_gis", + "drf_spectacular", # api-docs "django_filters", # for filtering rest endpoints + "corsheaders", # Your apps "vbos.users", + "vbos.datasets", ) # https://docs.djangoproject.com/en/2.0/topics/http/middleware/ MIDDLEWARE = ( "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", + "corsheaders.middleware.CorsMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", @@ -47,7 +53,9 @@ class Common(Configuration): # Postgres DATABASES = { "default": dj_database_url.config( - default="postgis://postgres:@postgres:5432/vbos", + default=os.getenv( + "DJANGO_DB_URL", "postgis://postgres:@postgres:5432/vbos" + ), conn_max_age=int(os.getenv("POSTGRES_CONN_MAX_AGE", 600)), ) } @@ -187,4 +195,12 @@ class Common(Configuration): "rest_framework.authentication.SessionAuthentication", "rest_framework.authentication.TokenAuthentication", ), + "DEFAULT_SCHEMA_CLASS": "drf_spectacular.openapi.AutoSchema", + } + + SPECTACULAR_SETTINGS = { + "TITLE": "VBOS-API", + "DESCRIPTION": "VBoS Management Information System API", + "VERSION": "1.0.0", + "SERVE_INCLUDE_SCHEMA": False, } diff --git a/vbos/config/local.py b/vbos/config/local.py index 37be7b5..062d5e9 100755 --- a/vbos/config/local.py +++ b/vbos/config/local.py @@ -14,3 +14,6 @@ class Local(Common): EMAIL_HOST = "localhost" EMAIL_PORT = 1025 EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" + + # CORS + CORS_ALLOW_ALL_ORIGINS = True diff --git a/vbos/datasets/__init__.py b/vbos/datasets/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vbos/datasets/admin.py b/vbos/datasets/admin.py new file mode 100644 index 0000000..ef2f4bb --- /dev/null +++ b/vbos/datasets/admin.py @@ -0,0 +1,12 @@ +from django.contrib.gis import admin +from .models import VectorDataset, VectorItem + + +@admin.register(VectorDataset) +class VectorDatasetAdmin(admin.ModelAdmin): + list_display = ["id", "name", "created", "updated"] + + +@admin.register(VectorItem) +class VectorItemAdmin(admin.GISModelAdmin): + list_display = ["id", "metadata"] diff --git a/vbos/datasets/migrations/0001_initial.py b/vbos/datasets/migrations/0001_initial.py new file mode 100644 index 0000000..a4df174 --- /dev/null +++ b/vbos/datasets/migrations/0001_initial.py @@ -0,0 +1,64 @@ +# Generated by Django 5.2.5 on 2025-08-29 16:45 + +import django.contrib.gis.db.models.fields +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="VectorDataset", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ("name", models.CharField(max_length=155)), + ("created", models.DateTimeField(auto_now_add=True)), + ("updated", models.DateTimeField(auto_now=True)), + ], + options={ + "ordering": ["id"], + }, + ), + migrations.CreateModel( + name="VectorItem", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "geometry", + django.contrib.gis.db.models.fields.GeometryField(srid=4326), + ), + ("metadata", models.JSONField(blank=True, default=dict, null=True)), + ( + "dataset", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="datasets.vectordataset", + ), + ), + ], + options={ + "ordering": ["id"], + }, + ), + ] diff --git a/vbos/datasets/migrations/__init__.py b/vbos/datasets/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vbos/datasets/models.py b/vbos/datasets/models.py new file mode 100644 index 0000000..81aecad --- /dev/null +++ b/vbos/datasets/models.py @@ -0,0 +1,25 @@ +from django.contrib.gis.db import models + + +class VectorDataset(models.Model): + name = models.CharField(max_length=155) + created = models.DateTimeField(auto_now_add=True) + updated = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name + + class Meta: + ordering = ["id"] + + +class VectorItem(models.Model): + dataset = models.ForeignKey(VectorDataset, on_delete=models.CASCADE) + geometry = models.GeometryField() + metadata = models.JSONField(default=dict, blank=True, null=True) + + def __str__(self): + return self.id + + class Meta: + ordering = ["id"] diff --git a/vbos/datasets/pagination.py b/vbos/datasets/pagination.py new file mode 100644 index 0000000..3754a57 --- /dev/null +++ b/vbos/datasets/pagination.py @@ -0,0 +1,7 @@ +from rest_framework.pagination import PageNumberPagination + + +class StandardResultsSetPagination(PageNumberPagination): + page_size = 20 + page_size_query_param = "page_size" + max_page_size = 200 diff --git a/vbos/datasets/serializers.py b/vbos/datasets/serializers.py new file mode 100644 index 0000000..2409c7d --- /dev/null +++ b/vbos/datasets/serializers.py @@ -0,0 +1,32 @@ +from rest_framework import serializers +from rest_framework_gis.serializers import GeoFeatureModelSerializer + +from .models import VectorDataset, VectorItem + + +class VectorDatasetSerializer(serializers.ModelSerializer): + class Meta: + model = VectorDataset + fields = "__all__" + + +class VectorItemSerializer(GeoFeatureModelSerializer): + class Meta: + model = VectorItem + geo_field = "geometry" + fields = ["id", "metadata"] + + def get_properties(self, instance, fields): + # This is a PostgreSQL HStore field, which django maps to a dict + return instance.metadata + + def unformat_geojson(self, feature): + attrs = { + self.Meta.geo_field: feature["geometry"], + "metadata": feature["properties"], + } + + if self.Meta.bbox_geo_field and "bbox" in feature: + attrs[self.Meta.bbox_geo_field] = Polygon.from_bbox(feature["bbox"]) + + return attrs diff --git a/vbos/datasets/test/__init__.py b/vbos/datasets/test/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/vbos/datasets/test/test_views.py b/vbos/datasets/test/test_views.py new file mode 100644 index 0000000..0e8bd68 --- /dev/null +++ b/vbos/datasets/test/test_views.py @@ -0,0 +1,78 @@ +from rest_framework import status +from rest_framework.test import APITestCase +from django.urls import reverse +from django.contrib.gis.geos import Polygon, LineString, Point + +from ..models import VectorDataset, VectorItem +from ...users.test.factories import UserFactory + + +class TestVectorDatasetListDetailViews(APITestCase): + def setUp(self): + self.user = UserFactory() + self.dataset_1 = VectorDataset.objects.create(name="Boundaries") + self.dataset_2 = VectorDataset.objects.create(name="Roads") + self.url = reverse("datasets:vector-list") + + def test_vector_datasets_list(self): + req = self.client.get(self.url) + assert req.status_code == status.HTTP_200_OK + assert req.data.get("count") == 2 + assert req.data.get("results")[0]["name"] == "Boundaries" + assert req.data.get("results")[1]["name"] == "Roads" + + def test_vector_datasets_detail(self): + url = reverse("datasets:vector-detail", args=[self.dataset_1.id]) + req = self.client.get(url) + assert req.status_code == status.HTTP_200_OK + assert req.data.get("name") == "Boundaries" + assert req.data.get("created") + assert req.data.get("updated") + + +class TestVectorDatasetDataView(APITestCase): + def setUp(self): + self.user = UserFactory() + self.dataset_1 = VectorDataset.objects.create(name="Boundaries") + self.dataset_2 = VectorDataset.objects.create(name="Roads") + VectorItem.objects.create( + dataset=self.dataset_1, + geometry=Point(80.5, 10.232), + metadata={"type": "administrative", "name": "Point 1"}, + ) + VectorItem.objects.create( + dataset=self.dataset_1, + geometry=LineString([(0, 0), (0, 3), (3, 3), (3, 0), (6, 6), (0, 0)]), + metadata={"type": "administrative", "name": "Line 123"}, + ) + VectorItem.objects.create( + dataset=self.dataset_2, + geometry=Polygon([(0, 0), (0, 3), (3, 3), (3, 0), (0, 0)]), + metadata={"type": "administrative", "name": "Area 1"}, + ) + self.url = reverse("datasets:vector-data", args=[self.dataset_1.id]) + + def test_vector_datasets_data(self): + req = self.client.get(self.url) + assert req.status_code == status.HTTP_200_OK + assert req.data.get("count") == 2 + assert len(req.data.get("features")) == 2 + assert req.data.get("features")[0]["geometry"] == { + "type": "Point", + "coordinates": [80.5, 10.232], + } + assert req.data.get("features")[0]["properties"]["name"] == "Point 1" + assert req.data.get("features")[0]["properties"]["type"] == "administrative" + + # fetch second dataset's data + url = reverse("datasets:vector-data", args=[self.dataset_2.id]) + req = self.client.get(url) + assert req.status_code == status.HTTP_200_OK + assert req.data.get("count") == 1 + assert len(req.data.get("features")) == 1 + assert req.data.get("features")[0]["geometry"] == { + "type": "Polygon", + "coordinates": [ + [[0.0, 0.0], [0.0, 3.0], [3.0, 3.0], [3.0, 0.0], [0.0, 0.0]] + ], + } diff --git a/vbos/datasets/urls.py b/vbos/datasets/urls.py new file mode 100644 index 0000000..4d0d9c5 --- /dev/null +++ b/vbos/datasets/urls.py @@ -0,0 +1,19 @@ +from django.urls import path + +from . import views + +app_name = "datasets" + +urlpatterns = [ + path("vector/", views.VectorDatasetListView.as_view(), name="vector-list"), + path( + "vector//", + views.VectorDatasetDetailView.as_view(), + name="vector-detail", + ), + path( + "vector//data/", + views.VectorDatasetDataView.as_view(), + name="vector-data", + ), +] diff --git a/vbos/datasets/views.py b/vbos/datasets/views.py new file mode 100644 index 0000000..c54d740 --- /dev/null +++ b/vbos/datasets/views.py @@ -0,0 +1,30 @@ +from django.shortcuts import render +from rest_framework.generics import ListAPIView, RetrieveAPIView +from rest_framework.permissions import IsAuthenticatedOrReadOnly +from rest_framework_gis.pagination import GeoJsonPagination + +from .models import VectorDataset, VectorItem +from .pagination import StandardResultsSetPagination +from .serializers import VectorDatasetSerializer, VectorItemSerializer + + +class VectorDatasetListView(ListAPIView): + queryset = VectorDataset.objects.all() + serializer_class = VectorDatasetSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + pagination_class = StandardResultsSetPagination + + +class VectorDatasetDetailView(RetrieveAPIView): + queryset = VectorDataset.objects.all() + serializer_class = VectorDatasetSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + + +class VectorDatasetDataView(ListAPIView): + serializer_class = VectorItemSerializer + permission_classes = [IsAuthenticatedOrReadOnly] + pagination_class = GeoJsonPagination + + def get_queryset(self): + return VectorItem.objects.filter(dataset=self.kwargs.get("pk")) diff --git a/vbos/urls.py b/vbos/urls.py index 7db2a4e..6615d61 100755 --- a/vbos/urls.py +++ b/vbos/urls.py @@ -3,19 +3,34 @@ from django.conf.urls.static import static from django.contrib import admin from django.views.generic.base import RedirectView -from rest_framework.routers import DefaultRouter from rest_framework.authtoken import views +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView + from .users.views import UserViewSet -router = DefaultRouter() -router.register(r"users", UserViewSet) +API_BASE_URL = "api/v1" + +api_urls = [ + path( + f"{API_BASE_URL}/", + include(("vbos.users.urls", "vbos.users"), namespace="users"), + ), + path( + f"{API_BASE_URL}/", + include(("vbos.datasets.urls", "vbos.datasets"), namespace="datasets"), + ), +] urlpatterns = [ path("admin/", admin.site.urls), - path("api/v1/", include(router.urls)), + path("", include(api_urls)), path("api-token-auth/", views.obtain_auth_token), path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), - # the 'api-root' from django rest-frameworks default router - # http://www.django-rest-framework.org/api-guide/routers/#defaultrouter - re_path(r"^$", RedirectView.as_view(url=reverse_lazy("api-root"), permanent=False)), + # API-Docs + path(f"{API_BASE_URL}/schema/", SpectacularAPIView.as_view(), name="schema"), + path( + f"{API_BASE_URL}/docs/", + SpectacularSwaggerView.as_view(url_name="schema"), + name="swagger-ui", + ), ] + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/vbos/users/migrations/0003_alter_user_first_name.py b/vbos/users/migrations/0003_alter_user_first_name.py new file mode 100644 index 0000000..2e7c1ff --- /dev/null +++ b/vbos/users/migrations/0003_alter_user_first_name.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.7 on 2025-08-27 20:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("users", "0002_auto_20171227_2246"), + ] + + operations = [ + migrations.AlterField( + model_name="user", + name="first_name", + field=models.CharField( + blank=True, max_length=150, verbose_name="first name" + ), + ), + ] diff --git a/vbos/users/test/test_views.py b/vbos/users/test/test_views.py index f48173f..0b9aef1 100644 --- a/vbos/users/test/test_views.py +++ b/vbos/users/test/test_views.py @@ -18,7 +18,7 @@ class TestUserListTestCase(APITestCase): """ def setUp(self): - self.url = reverse("user-list") + self.url = reverse("users:user-list") self.user_data = factory.build(dict, FACTORY_CLASS=UserFactory) def test_post_request_with_no_data_fails(self): @@ -41,7 +41,7 @@ class TestUserDetailTestCase(APITestCase): def setUp(self): self.user = UserFactory() - self.url = reverse("user-detail", kwargs={"pk": self.user.pk}) + self.url = reverse("users:user-detail", kwargs={"pk": self.user.pk}) self.client.credentials(HTTP_AUTHORIZATION=f"Token {self.user.auth_token}") def test_get_request_returns_a_given_user(self): diff --git a/vbos/users/urls.py b/vbos/users/urls.py new file mode 100644 index 0000000..c4eb04e --- /dev/null +++ b/vbos/users/urls.py @@ -0,0 +1,13 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter + +from . import views + +app_name = "users" + +router = DefaultRouter() +router.register(r"users", views.UserViewSet) + +urlpatterns = [ + path("", include(router.urls)), +]