Skip to content
Merged
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
25 changes: 10 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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
Expand Down
20 changes: 20 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
9 changes: 6 additions & 3 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
18 changes: 17 additions & 1 deletion vbos/config/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ class Common(Configuration):

INSTALLED_APPS = (
"django.contrib.admin",
"django.contrib.gis",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
Expand All @@ -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",
Expand All @@ -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)),
)
}
Expand Down Expand Up @@ -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,
}
3 changes: 3 additions & 0 deletions vbos/config/local.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Empty file added vbos/datasets/__init__.py
Empty file.
12 changes: 12 additions & 0 deletions vbos/datasets/admin.py
Original file line number Diff line number Diff line change
@@ -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"]
64 changes: 64 additions & 0 deletions vbos/datasets/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -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"],
},
),
]
Empty file.
25 changes: 25 additions & 0 deletions vbos/datasets/models.py
Original file line number Diff line number Diff line change
@@ -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"]
7 changes: 7 additions & 0 deletions vbos/datasets/pagination.py
Original file line number Diff line number Diff line change
@@ -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
32 changes: 32 additions & 0 deletions vbos/datasets/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added vbos/datasets/test/__init__.py
Empty file.
78 changes: 78 additions & 0 deletions vbos/datasets/test/test_views.py
Original file line number Diff line number Diff line change
@@ -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]]
],
}
19 changes: 19 additions & 0 deletions vbos/datasets/urls.py
Original file line number Diff line number Diff line change
@@ -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/<int:pk>/",
views.VectorDatasetDetailView.as_view(),
name="vector-detail",
),
path(
"vector/<int:pk>/data/",
views.VectorDatasetDataView.as_view(),
name="vector-data",
),
]
Loading