diff --git a/README.md b/README.md index ad061e8..4a4919a 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,7 @@ You can replace `makemigrations`, `migrate`, etc. with any Django command as nee [http://localhost:8000/](http://localhost:8000/) - **API Docs:** - [http://localhost:8000/api/docs/](http://localhost:8000/api/docs/) + [http://localhost:8000/api/docs](http://localhost:8000/api/docs) - **Sample Locations API Endpoint Example:** [http://localhost:8000/api/samplelocations/](http://localhost:8000/api/samplelocations/) diff --git a/docker-compose.yml b/docker-compose.yml index 97d1a3f..3a4449c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,5 @@ +version: '3.9' + services: db: image: timescale/timescaledb-ha:pg17-all diff --git a/geodjango/geodjango/api.py b/geodjango/geodjango/api.py index 31371dd..b974c8e 100644 --- a/geodjango/geodjango/api.py +++ b/geodjango/geodjango/api.py @@ -1,5 +1,8 @@ -from ninja import NinjaAPI +from ninja import NinjaAPI, Redoc api = NinjaAPI() -api.add_router('/samplelocations/', 'samplelocations.api.router') \ No newline at end of file +api.add_router('/location', 'samplelocations.api.locations.router', tags=['locations']) +# api.add_router('/wells', 'samplelocations.api.wells.router', tags=['wells']) +# api.add_router('/contacts', 'samplelocations.api.contacts.router', tags=['contacts']) +api.add_router('/thing', 'samplelocations.api.thing.router', tags=['things']) \ No newline at end of file diff --git a/geodjango/geodjango/urls.py b/geodjango/geodjango/urls.py index ccad033..6ee5488 100644 --- a/geodjango/geodjango/urls.py +++ b/geodjango/geodjango/urls.py @@ -16,7 +16,7 @@ """ from django.contrib import admin from django.urls import path -from .api import api +from geodjango.api import api from samplelocations import views urlpatterns = [ diff --git a/geodjango/samplelocations/admin.py b/geodjango/samplelocations/admin.py index 4458283..9f126f5 100644 --- a/geodjango/samplelocations/admin.py +++ b/geodjango/samplelocations/admin.py @@ -3,7 +3,7 @@ from django import forms from django.contrib.gis.admin import GISModelAdmin from django.contrib.gis.geos import Point -from samplelocations.models import Location, Thing, WellThing, SpringThing, Location_Thing_Junction, Sensor, Datastream, Observation, \ +from samplelocations.models import Location, Thing, Location_Thing_Junction, Sensor, Datastream, Observation, \ GroundwaterLevelObservation, Sample class LocationForm(forms.ModelForm): @@ -29,14 +29,6 @@ class LocationAdmin(GISModelAdmin): class ThingAdmin(ModelAdmin): pass -@admin.register(WellThing) -class WellThingAdmin(ModelAdmin): - pass - -@admin.register(SpringThing) -class SpringThingAdmin(ModelAdmin): - pass - @admin.register(Location_Thing_Junction) class LocationThingJunctionAdmin(ModelAdmin): pass @@ -61,6 +53,14 @@ class GroundwaterLevelObservationAdmin(ModelAdmin): class SampleAdmin(ModelAdmin): pass +# @admin.register(WellThing) +# class WellThingAdmin(ModelAdmin): +# pass + +# @admin.register(SpringThing) +# class SpringThingAdmin(ModelAdmin): +# pass + #admin.site.register(Lexicon) #admin.site.register(WellScreen) #admin.site.register(Equipment) diff --git a/geodjango/samplelocations/api.py b/geodjango/samplelocations/api.py deleted file mode 100644 index 724224e..0000000 --- a/geodjango/samplelocations/api.py +++ /dev/null @@ -1,20 +0,0 @@ -from ninja import Router -from .models import Location - -router = Router() - -@router.get('') -def list_locations(request): - """ - List all locations. - """ - locations = Location.objects.all() - return [ - { - 'id': loc.location_id, - 'name': loc.name, - 'coordinates': loc.coordinate, - 'date_created': loc.date_created.isoformat(), - } - for loc in locations - ] diff --git a/geodjango/samplelocations/api/__init__.py b/geodjango/samplelocations/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/api/contacts.py b/geodjango/samplelocations/api/contacts.py new file mode 100644 index 0000000..d1a435f --- /dev/null +++ b/geodjango/samplelocations/api/contacts.py @@ -0,0 +1,22 @@ +# from ninja import Router +# from ..models import Well, Contact +# from django.shortcuts import get_object_or_404 + +# router = Router() + +# @router.post("") +# def post_contact( +# request, +# well_id: int, +# name: str, +# email: str, +# phone: str = None +# ): +# well = get_object_or_404(Well, id=well_id) +# contact = Contact.objects.create(name=name, email=email, phone=phone) +# # Create or update owner for the location if needed +# location = well.location +# owner = location.owner +# owner.contact = contact +# owner.save() +# return {"contact_id": contact.id, "owner_id": owner.id} \ No newline at end of file diff --git a/geodjango/samplelocations/api/crud.py b/geodjango/samplelocations/api/crud.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/api/locations.py b/geodjango/samplelocations/api/locations.py new file mode 100644 index 0000000..ed56c7c --- /dev/null +++ b/geodjango/samplelocations/api/locations.py @@ -0,0 +1,38 @@ +from ninja import Router +from samplelocations.models import Location +from samplelocations.schemas import LocationSchema, NotFoundSchema +from typing import List + +router = Router() + +@router.get("") +def get_locations(request) -> List[LocationSchema]: + """ + List all sample locations. + """ + locations = Location.objects.all() + + response = [ + { + "location_id": location.location_id, + "coordinates": f"POINT({location.coordinate.x} {location.coordinate.y} {location.coordinate.z})", + "date_created": location.date_created.isoformat(), + } + for location in locations + ] + + return response + +@router.get("/{location_id}", response={200: LocationSchema, 404: NotFoundSchema}) +def get_location_by_id(request, location_id: int): + locations = Location.objects.filter(location_id=location_id) + if not locations.exists(): + return 404, {"detail": f"Location with location_id {location_id} not found"} + + location = locations.first() + response = { + "location_id": location.location_id, + "coordinates": f"POINT({location.coordinate.x} {location.coordinate.y} {location.coordinate.z})", + "date_created": location.date_created.isoformat(), + } + return response \ No newline at end of file diff --git a/geodjango/samplelocations/api/thing.py b/geodjango/samplelocations/api/thing.py new file mode 100644 index 0000000..d4c7c0b --- /dev/null +++ b/geodjango/samplelocations/api/thing.py @@ -0,0 +1,93 @@ +from ninja import Router +from samplelocations.models import Thing, Location, Location_Thing_Junction +from samplelocations.schemas import FeatureCollection, NotFoundSchema, WellProperties, SpringProperties +from django.contrib.gis.geos import Point +from django.shortcuts import get_object_or_404 +from django.forms.models import model_to_dict +from django.http import HttpResponse +from typing import List, Tuple + +router = Router() + +def get_things(thing_id: int | None = None) -> List[Thing]: + """ + Retrieve all things or a specific thing by ID. + """ + if thing_id is None: + return Thing.objects.all() + else: + # If a specific thing ID is provided, return that thing: + return [Thing.objects.filter(thing_id__in=[thing_id]).first()] + +def construct_feature_collection(things: List[Thing]) -> FeatureCollection: + """ + Construct a GeoJSON FeatureCollection from a list of Thing objects. + """ + """ + Jacob's notes during development: 2025-07-28 + A disadvantage of Django ORM is that you can't make more complicated queries. + From what I understand, you can use Django ORM to get all the things and their related locations, + but you can't easily filter or join them in a single query. The "joining" has to be done in + Python code after fetching the data, which can be less efficient because it's slower than + SQL and you have to make more SQL queries to get the related data. + + Also, the filtering is kind of confusing... + """ + thing_ids = [thing.thing_id for thing in things] + location_thing_junctions = Location_Thing_Junction.objects.filter(thing_id__in=thing_ids) + location_ids = [junction.location_id.location_id for junction in location_thing_junctions] + locations = Location.objects.filter(location_id__in=location_ids) + + features = [] + for thing in things: + thing_dict = model_to_dict(thing) + thing_dict["location_id"] = thing_dict["location_id"][0].location_id + thing_dict["date_created"] = thing.date_created.isoformat() + thing_dict["thing_type"] = thing.get_thing_type_display() # Get human-readable type + if thing.thing_type == "W": + thing_properties = WellProperties(**thing_dict) + elif thing.thing_type == "S": + thing_properties = SpringProperties(**thing_dict) + + locations = thing.location_id.all() + + # TODO: assuming, for now, that each thing has a single location. this will have to change if we allow multiple locations per thing. + location = locations[0] + + feature = { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [location.coordinate.x, location.coordinate.y, location.coordinate.z], + }, + "properties": thing_properties.dict(), + } + + features.append(feature) + + response = FeatureCollection( + type="FeatureCollection", + features=features + ) + + return response + +@router.get('') +def get_all_things(request): + """ + List all things. + """ + things = get_things() + response = construct_feature_collection(things) + return response + +@router.get('/{thing_id}', response={200: FeatureCollection, 404: NotFoundSchema}) +def get_thing_by_id(request, thing_id: int): + """ + Retrieve a specific thing by its ID. + """ + thing = get_things(thing_id=thing_id) + if thing == [None]: + return 404, {"detail": f"Thing with id {thing_id} not found"} + response = construct_feature_collection(thing) + return response \ No newline at end of file diff --git a/geodjango/samplelocations/api/wells.py b/geodjango/samplelocations/api/wells.py new file mode 100644 index 0000000..a4dd44a --- /dev/null +++ b/geodjango/samplelocations/api/wells.py @@ -0,0 +1,55 @@ +# from ninja import Router +# from ..models import Location, Well, WellScreen, Lexicon +# from django.shortcuts import get_object_or_404 + +# router = Router() + + +# @router.post("") +# def post_well( +# request, +# location_id: int, +# ose_pod_id: str = None, +# api_id: str = "", +# usgs_id: str = None, +# well_depth: float = None, +# hole_depth: float = None, +# well_type_id: int = None, +# casing_diameter: float = None, +# casing_depth: float = None, +# casing_description: str = None, +# construction_notes: str = None, +# formation_zone_id: int = None +# ): +# location = get_object_or_404(Location, id=location_id) +# well_type = Lexicon.objects.filter(id=well_type_id).first() if well_type_id else None +# formation_zone = Lexicon.objects.filter(id=formation_zone_id).first() if formation_zone_id else None +# well = Well.objects.create( +# location=location, +# ose_pod_id=ose_pod_id, +# api_id=api_id, +# usgs_id=usgs_id, +# well_depth=well_depth, +# hole_depth=hole_depth, +# well_type=well_type, +# casing_diameter=casing_diameter, +# casing_depth=casing_depth, +# casing_description=casing_description, +# construction_notes=construction_notes, +# formation_zone=formation_zone +# ) +# return {"id": well.id, "location": well.location.id} + + +# @router.post("well-screens/") +# def post_well_screen(request, well_id: int, screen_depth_top: float, screen_depth_bottom: float, screen_type_id: int = None): +# well = get_object_or_404(Well, id=well_id) +# screen_type = Lexicon.objects.filter(id=screen_type_id).first() if screen_type_id else None +# screen = WellScreen.objects.create( +# well=well, +# screen_depth_top=screen_depth_top, +# screen_depth_bottom=screen_depth_bottom, +# screen_type=screen_type +# ) +# return {"id": screen.id, "well": screen.well.id} + diff --git a/geodjango/samplelocations/migrations/0001_initial.py b/geodjango/samplelocations/migrations/0001_initial.py index d6c175a..fafbb0d 100644 --- a/geodjango/samplelocations/migrations/0001_initial.py +++ b/geodjango/samplelocations/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.2.3 on 2025-06-26 15:14 +# Generated by Django 5.2.3 on 2025-07-30 18:04 import django.contrib.gis.db.models.fields import django.db.models.deletion @@ -14,114 +14,124 @@ class Migration(migrations.Migration): operations = [ migrations.CreateModel( - name='Contact', + name='Datastream', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('email', models.EmailField(max_length=254)), - ('phone', models.CharField(blank=True, max_length=20, null=True)), - ('date_created', models.DateTimeField(auto_now_add=True)), + ('datastream_id', models.BigAutoField(primary_key=True, serialize=False)), + ('observed_property', models.CharField(max_length=100)), + ('release_status', models.BooleanField(default=False)), ], - options={ - 'verbose_name': 'Contact', - 'verbose_name_plural': 'Contacts', - 'ordering': ['name'], - }, ), migrations.CreateModel( - name='Lexicon', + name='Observation', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100, unique=True)), - ('description', models.CharField(blank=True, max_length=255, null=True)), - ('date_created', models.DateTimeField(auto_now_add=True)), + ('observation_id', models.BigAutoField(primary_key=True, serialize=False)), + ('observed_value', models.FloatField(help_text='The value of the observation')), + ('release_status', models.BooleanField(default=False)), + ('datastream_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='samplelocations.datastream', verbose_name='related datastream')), ], - options={ - 'verbose_name': 'Lexicon', - 'verbose_name_plural': 'Lexicons', - 'ordering': ['name'], - }, ), migrations.CreateModel( - name='Owner', + name='Location', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('description', models.CharField(blank=True, max_length=255, null=True)), + ('location_id', models.BigAutoField(primary_key=True, serialize=False)), + ('coordinate', django.contrib.gis.db.models.fields.PointField(dim=3, srid=4326)), ('date_created', models.DateTimeField(auto_now_add=True)), - ('contact', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owners', to='samplelocations.contact')), ], options={ - 'verbose_name': 'Owner', - 'verbose_name_plural': 'Owners', - 'ordering': ['name'], + 'verbose_name': 'Location', + 'verbose_name_plural': 'Locations', + 'db_table_comment': "This table stores point locations on the earth's surface", }, ), migrations.CreateModel( - name='SampleLocation', + name='Sample', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=100)), - ('description', models.CharField(blank=True, max_length=255, null=True)), - ('visible', models.BooleanField(default=False)), - ('point', django.contrib.gis.db.models.fields.PointField(srid=4326)), - ('date_created', models.DateTimeField(auto_now_add=True)), - ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='samplelocations', to='samplelocations.owner')), + ('sample_id', models.BigAutoField(primary_key=True, serialize=False)), + ('sample_matrix', models.CharField(choices=[('GW', 'Groundwater'), ('S', 'Soil')], default='GW', max_length=2, verbose_name='type of sample')), + ('sample_date', models.DateTimeField()), + ('sample_notes', models.TextField(blank=True, null=True)), ], - options={ - 'verbose_name': 'Sample Location', - 'verbose_name_plural': 'Sample Locations', - 'ordering': ['name'], - }, ), migrations.CreateModel( - name='Equipment', + name='Sensor', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('equipment_type', models.CharField(max_length=50)), - ('model', models.CharField(max_length=50)), - ('serial_no', models.CharField(max_length=50)), - ('date_installed', models.DateTimeField(blank=True, null=True)), - ('date_removed', models.DateTimeField(blank=True, null=True)), - ('recording_interval', models.IntegerField(blank=True, null=True)), - ('equipment_notes', models.CharField(blank=True, max_length=50, null=True)), - ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='equipment', to='samplelocations.samplelocation')), + ('sensor_id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(blank=True, max_length=100, null=True)), + ('serial_number', models.CharField(blank=True, max_length=50, null=True)), + ('install_date', models.DateTimeField(blank=True, null=True)), + ('model', models.CharField(blank=True, max_length=50, null=True)), + ('notes', models.TextField(blank=True, null=True)), ], ), migrations.CreateModel( - name='Spring', + name='GroundwaterLevelObservation', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('description', models.CharField(blank=True, max_length=255, null=True)), - ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='springs', to='samplelocations.samplelocation')), + ('groundwater_level_observation_id', models.BigAutoField(primary_key=True, serialize=False)), + ('observation_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, related_name='groundwater_level_observations', to='samplelocations.observation', verbose_name='related observation')), ], + bases=('samplelocations.observation',), ), migrations.CreateModel( - name='Well', + name='Location_Thing_Junction', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('ose_pod_id', models.CharField(blank=True, max_length=50, null=True)), - ('api_id', models.CharField(blank=True, default='', max_length=50)), - ('usgs_id', models.CharField(blank=True, max_length=50, null=True)), - ('well_depth', models.FloatField(blank=True, help_text='feet below ground surface', null=True)), - ('hole_depth', models.FloatField(blank=True, help_text='feet below ground surface', null=True)), - ('casing_diameter', models.FloatField(blank=True, help_text='inches', null=True)), - ('casing_depth', models.FloatField(blank=True, help_text='feet below ground surface', null=True)), - ('casing_description', models.CharField(blank=True, max_length=50, null=True)), - ('construction_notes', models.CharField(blank=True, max_length=250, null=True)), - ('formation_zone', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wells_by_formation', to='samplelocations.lexicon')), - ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='wells', to='samplelocations.samplelocation')), - ('well_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='wells_by_type', to='samplelocations.lexicon')), + ('effective_start', models.DateTimeField()), + ('effective_end', models.DateTimeField()), + ('location_id', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations_things', to='samplelocations.location', verbose_name='related location')), ], + options={ + 'db_table_comment': 'Junction table linking Location and Thing models', + }, + ), + migrations.AddField( + model_name='observation', + name='sample', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='samplelocations.sample', verbose_name='related sample'), + ), + migrations.AddField( + model_name='datastream', + name='sensor_id', + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='datastreams', to='samplelocations.sensor', verbose_name='related sensor'), ), migrations.CreateModel( - name='WellScreen', + name='Thing', fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('screen_depth_top', models.FloatField(help_text='feet below ground surface')), - ('screen_depth_bottom', models.FloatField(help_text='feet below ground surface')), - ('screen_type', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='well_screens_by_type', to='samplelocations.lexicon')), - ('well', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='screens', to='samplelocations.well')), + ('thing_id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100, unique=True)), + ('thing_type', models.CharField(choices=[('W', 'Well'), ('S', 'Spring')], default='W', max_length=2, verbose_name='type of thing')), + ('release_status', models.BooleanField(default=False)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('well_depth_ft', models.FloatField(blank=True, help_text='well depth feet below ground surface', null=True)), + ('hole_depth_ft', models.FloatField(blank=True, help_text='hole depth feet below ground surface', null=True)), + ('casing_diameter_ft', models.FloatField(blank=True, help_text='casing diameter in ft', null=True)), + ('casing_depth_ft', models.FloatField(blank=True, help_text='casing depth feet below ground surface', null=True)), + ('casing_description', models.CharField(blank=True, max_length=50, null=True)), + ('construction_notes', models.TextField(blank=True, null=True)), + ('spring_type', models.CharField(blank=True, max_length=255, null=True)), + ('location_id', models.ManyToManyField(related_name='things', through='samplelocations.Location_Thing_Junction', to='samplelocations.location', verbose_name='related location')), ], + options={ + 'verbose_name': 'Thing', + 'verbose_name_plural': 'Things', + }, + ), + migrations.AddField( + model_name='sample', + name='thing_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='samples', to='samplelocations.thing', verbose_name='related thing'), + ), + migrations.AddField( + model_name='location_thing_junction', + name='thing_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations_things', to='samplelocations.thing', verbose_name='related thing'), + ), + migrations.AddField( + model_name='datastream', + name='thing_id', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='datastreams', to='samplelocations.thing', verbose_name='related thing'), + ), + migrations.AddConstraint( + model_name='location_thing_junction', + constraint=models.UniqueConstraint(fields=('location_id', 'thing_id'), name='unique_location_thing'), ), ] diff --git a/geodjango/samplelocations/migrations/0002_datastream_observation_location_sample_sensor_thing_and_more.py b/geodjango/samplelocations/migrations/0002_datastream_observation_location_sample_sensor_thing_and_more.py deleted file mode 100644 index 1c34efa..0000000 --- a/geodjango/samplelocations/migrations/0002_datastream_observation_location_sample_sensor_thing_and_more.py +++ /dev/null @@ -1,213 +0,0 @@ -# Generated by Django 5.2.3 on 2025-07-11 16:53 - -import django.contrib.gis.db.models.fields -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('samplelocations', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Datastream', - fields=[ - ('datastream_id', models.BigAutoField(primary_key=True, serialize=False)), - ('observed_property', models.CharField(max_length=100)), - ('release_status', models.BooleanField(default=False)), - ], - ), - migrations.CreateModel( - name='Observation', - fields=[ - ('observation_id', models.BigAutoField(primary_key=True, serialize=False)), - ('observed_value', models.FloatField(help_text='The value of the observation')), - ('release_status', models.BooleanField(default=False)), - ('datastream', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='samplelocations.datastream', verbose_name='related datastream')), - ], - ), - migrations.CreateModel( - name='Location', - fields=[ - ('location_id', models.BigAutoField(primary_key=True, serialize=False)), - ('coordinate', django.contrib.gis.db.models.fields.PointField(dim=3, srid=4326)), - ('date_created', models.DateTimeField(auto_now_add=True)), - ], - options={ - 'verbose_name': 'Location', - 'verbose_name_plural': 'Locations', - 'db_table_comment': "This table stores point locations on the earth's surface", - }, - ), - migrations.CreateModel( - name='Sample', - fields=[ - ('sample_id', models.BigAutoField(primary_key=True, serialize=False)), - ('sample_date', models.DateTimeField()), - ('sample_notes', models.TextField(blank=True, null=True)), - ], - ), - migrations.CreateModel( - name='Sensor', - fields=[ - ('sensor_id', models.BigAutoField(primary_key=True, serialize=False)), - ('serial_number', models.CharField(blank=True, max_length=50, null=True)), - ('install_date', models.DateTimeField(blank=True, null=True)), - ('model', models.CharField(blank=True, max_length=50, null=True)), - ('notes', models.TextField(blank=True, null=True)), - ], - ), - migrations.CreateModel( - name='Thing', - fields=[ - ('thing_id', models.BigAutoField(primary_key=True, serialize=False)), - ('name', models.CharField(max_length=100, unique=True)), - ('release_status', models.BooleanField(default=False)), - ('date_created', models.DateTimeField(auto_now_add=True)), - ], - options={ - 'verbose_name': 'Thing', - 'verbose_name_plural': 'Things', - }, - ), - migrations.RemoveField( - model_name='owner', - name='contact', - ), - migrations.RemoveField( - model_name='equipment', - name='location', - ), - migrations.RemoveField( - model_name='well', - name='well_type', - ), - migrations.RemoveField( - model_name='wellscreen', - name='screen_type', - ), - migrations.RemoveField( - model_name='well', - name='formation_zone', - ), - migrations.RemoveField( - model_name='samplelocation', - name='owner', - ), - migrations.RemoveField( - model_name='well', - name='location', - ), - migrations.RemoveField( - model_name='spring', - name='location', - ), - migrations.RemoveField( - model_name='wellscreen', - name='well', - ), - migrations.CreateModel( - name='GroundwaterLevelObservation', - fields=[ - ('groundwater_level_observation_id', models.BigAutoField(primary_key=True, serialize=False)), - ('observation_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, related_name='groundwater_level_observations', to='samplelocations.observation', verbose_name='related observation')), - ], - bases=('samplelocations.observation',), - ), - migrations.CreateModel( - name='Location_Thing_Junction', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('effective_start', models.DateTimeField()), - ('effective_end', models.DateTimeField()), - ('location', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations_things', to='samplelocations.location', verbose_name='related location')), - ], - options={ - 'db_table_comment': 'Junction table linking Location and Thing models', - }, - ), - migrations.AddField( - model_name='observation', - name='sample', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='observations', to='samplelocations.sample', verbose_name='related sample'), - ), - migrations.AddField( - model_name='datastream', - name='sensor_id', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='datastreams', to='samplelocations.sensor', verbose_name='related sensor'), - ), - migrations.CreateModel( - name='SpringThing', - fields=[ - ('springthing_id', models.BigAutoField(primary_key=True, serialize=False)), - ('thing_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, related_name='springthings', to='samplelocations.thing', verbose_name='related thing')), - ('description', models.CharField(blank=True, max_length=255, null=True)), - ], - bases=('samplelocations.thing',), - ), - migrations.CreateModel( - name='WellThing', - fields=[ - ('wellthing_id', models.BigAutoField(primary_key=True, serialize=False)), - ('thing_ptr', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, related_name='wellthings', to='samplelocations.thing', verbose_name='related thing')), - ('well_depth', models.FloatField(blank=True, help_text='feet below ground surface', null=True)), - ('hole_depth', models.FloatField(blank=True, help_text='feet below ground surface', null=True)), - ('casing_diameter', models.FloatField(blank=True, help_text='inches', null=True)), - ('casing_depth', models.FloatField(blank=True, help_text='feet below ground surface', null=True)), - ('casing_description', models.CharField(blank=True, max_length=50, null=True)), - ('construction_notes', models.TextField(blank=True, null=True)), - ], - bases=('samplelocations.thing',), - ), - migrations.AddField( - model_name='thing', - name='location', - field=models.ManyToManyField(related_name='things', through='samplelocations.Location_Thing_Junction', to='samplelocations.location', verbose_name='related location'), - ), - migrations.AddField( - model_name='sample', - name='thing', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='samples', to='samplelocations.thing', verbose_name='related thing'), - ), - migrations.AddField( - model_name='location_thing_junction', - name='thing', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='locations_things', to='samplelocations.thing', verbose_name='related thing'), - ), - migrations.AddField( - model_name='datastream', - name='thing', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='datastreams', to='samplelocations.thing', verbose_name='related thing'), - ), - migrations.DeleteModel( - name='Contact', - ), - migrations.DeleteModel( - name='Equipment', - ), - migrations.DeleteModel( - name='Lexicon', - ), - migrations.DeleteModel( - name='Owner', - ), - migrations.DeleteModel( - name='SampleLocation', - ), - migrations.DeleteModel( - name='Spring', - ), - migrations.DeleteModel( - name='Well', - ), - migrations.DeleteModel( - name='WellScreen', - ), - migrations.AddConstraint( - model_name='location_thing_junction', - constraint=models.UniqueConstraint(fields=('location', 'thing'), name='unique_location_thing'), - ), - ] diff --git a/geodjango/samplelocations/migrations/0003_remove_location_thing_junction_unique_location_thing_and_more.py b/geodjango/samplelocations/migrations/0003_remove_location_thing_junction_unique_location_thing_and_more.py deleted file mode 100644 index a9337d4..0000000 --- a/geodjango/samplelocations/migrations/0003_remove_location_thing_junction_unique_location_thing_and_more.py +++ /dev/null @@ -1,87 +0,0 @@ -# Generated by Django 5.2.3 on 2025-07-11 17:33 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('samplelocations', '0002_datastream_observation_location_sample_sensor_thing_and_more'), - ] - - operations = [ - migrations.RemoveConstraint( - model_name='location_thing_junction', - name='unique_location_thing', - ), - migrations.RenameField( - model_name='datastream', - old_name='thing', - new_name='thing_id', - ), - migrations.RenameField( - model_name='location_thing_junction', - old_name='location', - new_name='location_id', - ), - migrations.RenameField( - model_name='location_thing_junction', - old_name='thing', - new_name='thing_id', - ), - migrations.RenameField( - model_name='observation', - old_name='datastream', - new_name='datastream_id', - ), - migrations.RenameField( - model_name='sample', - old_name='thing', - new_name='thing_id', - ), - migrations.RenameField( - model_name='thing', - old_name='location', - new_name='location_id', - ), - migrations.RemoveField( - model_name='wellthing', - name='casing_depth', - ), - migrations.RemoveField( - model_name='wellthing', - name='casing_diameter', - ), - migrations.RemoveField( - model_name='wellthing', - name='hole_depth', - ), - migrations.RemoveField( - model_name='wellthing', - name='well_depth', - ), - migrations.AddField( - model_name='wellthing', - name='casing_depth_ft', - field=models.FloatField(blank=True, help_text='casing depth feet below ground surface', null=True), - ), - migrations.AddField( - model_name='wellthing', - name='casing_diameter_ft', - field=models.FloatField(blank=True, help_text='casing diameter in ft', null=True), - ), - migrations.AddField( - model_name='wellthing', - name='hole_depth_ft', - field=models.FloatField(blank=True, help_text='hole depth feet below ground surface', null=True), - ), - migrations.AddField( - model_name='wellthing', - name='well_depth_ft', - field=models.FloatField(blank=True, help_text='well depth feet below ground surface', null=True), - ), - migrations.AddConstraint( - model_name='location_thing_junction', - constraint=models.UniqueConstraint(fields=('location_id', 'thing_id'), name='unique_location_thing'), - ), - ] diff --git a/geodjango/samplelocations/migrations/0004_sensor_name.py b/geodjango/samplelocations/migrations/0004_sensor_name.py deleted file mode 100644 index 901f494..0000000 --- a/geodjango/samplelocations/migrations/0004_sensor_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.2.3 on 2025-07-11 18:50 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('samplelocations', '0003_remove_location_thing_junction_unique_location_thing_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='sensor', - name='name', - field=models.CharField(blank=True, max_length=100, null=True), - ), - ] diff --git a/geodjango/samplelocations/models.py b/geodjango/samplelocations/models.py index 13be40f..7559afa 100644 --- a/geodjango/samplelocations/models.py +++ b/geodjango/samplelocations/models.py @@ -34,11 +34,24 @@ class Meta: #--------Thing model ----------- +# Define class-based choices for the 'thing_type' field. +# This allows for a more structured way to define and use choices in Django models. +# The format is CHOICE = " database value", "human-readable or display name" +class ThingType(models.TextChoices): + WELL = "W", "Well" + SPRING = "S", "Spring" class Thing(models.Model): """A base model representing a generic monitoring station (Thing)""" + thing_id = models.BigAutoField(primary_key=True) name = models.CharField(max_length=100, unique=True) + thing_type = models.CharField( + max_length=2, + choices=ThingType.choices, # Use the choices defined in the ThingType class. + default=ThingType.WELL, # Set a default value for the field. + verbose_name="type of thing" # Human-readable label for user interfaces like forms and admin panel. + ) release_status = models.BooleanField(default=False) date_created = models.DateTimeField(auto_now_add=True) # The 'location' field sets up the M:M relationship and specifies @@ -50,8 +63,19 @@ class Thing(models.Model): verbose_name= "related location" # Human-readable label for user interfaces like forms and the admin panel. ) + #Fields specific to a WELL + well_depth_ft = models.FloatField(blank=True, null=True, help_text="well depth feet below ground surface") + hole_depth_ft = models.FloatField(blank=True, null=True, help_text="hole depth feet below ground surface") + casing_diameter_ft = models.FloatField(blank=True, null=True, help_text="casing diameter in ft") + casing_depth_ft = models.FloatField(blank=True, null=True, help_text="casing depth feet below ground surface") + casing_description = models.CharField(max_length=50, blank=True, null=True) + construction_notes = models.TextField(blank=True, null=True) # Use TextField over CharField for long-form text of variable length without a predefined limit. + + #Fields specific to a SPRING + spring_type = models.CharField(max_length=255, blank=True, null=True) # e.g. "artesian", "subartesian", "thermal", etc. + def __str__(self): - return f"Thing object with id {self.thing_id} and name {self.name}" + return f"Thing object is a {self.thing_type} with name {self.name}" class Meta: verbose_name = "Thing" @@ -93,51 +117,6 @@ class Meta: db_table_comment = "Junction table linking Location and Thing models" -#--------WellThing model. Inherits all fields from Thing model ----------- - -class WellThing(Thing): - """ A specific type of monitoring station (Thing) representing a well.""" - wellthing_id = models.BigAutoField(primary_key=True) - # This field creates the inheritance link from WellThing back to Thing. - # The name 'thing_ptr' is a conventional naming choice in Django for the parent link field, - thing_ptr= models.OneToOneField( - Thing, - on_delete=models.CASCADE, - parent_link = True, - related_name='wellthings', - verbose_name="related thing" - ) - well_depth_ft = models.FloatField(blank=True, null=True, help_text="well depth feet below ground surface") - hole_depth_ft = models.FloatField(blank=True, null=True, help_text="hole depth feet below ground surface") - casing_diameter_ft = models.FloatField(blank=True, null=True, help_text="casing diameter in ft") - casing_depth_ft = models.FloatField(blank=True, null=True, help_text="casing depth feet below ground surface") - casing_description = models.CharField(max_length=50, blank=True, null=True) - construction_notes = models.TextField(blank=True, null=True) # Use TextField over CharField for long-form text of variable length without a predefined limit. - - def __str__(self): - return f"{self.name} (Well)" - - -#--------SpringThing model. Inherits all fields from Thing model ----------- - -class SpringThing(Thing): - """ A specific type of monitoring station (Thing) representing a spring.""" - springthing_id = models.BigAutoField(primary_key=True) - # This field creates the inheritance link from SpringThing back to Thing. - # The name 'thing_ptr' is a conventional naming choice in Django for the parent link field, - thing_ptr = models.OneToOneField( - Thing, - on_delete=models.CASCADE, - parent_link=True, - related_name='springthings', - verbose_name="related thing" - ) - description = models.CharField(max_length=255, blank=True, null=True) - - def __str__(self): - return f"{self.name} (Spring)" - - #--------Sensor model----------- #TODO: add a 'name' field to this model. class Sensor(models.Model): @@ -168,11 +147,22 @@ def __str__(self): #--------Sample model----------- +# Define choices for thhe 'sample_matrix' field. +# The format is CHOICE = " database value", "human-readable or display name" +class SampleMatrix(models.TextChoices): + GROUNDWATER = "GW", "Groundwater" + SOIL = "S", "Soil" class Sample(models.Model): """Represents a sample collected from a Thing""" sample_id = models.BigAutoField(primary_key=True) thing_id = models.ForeignKey(Thing, on_delete=models.CASCADE, related_name="samples", verbose_name="related thing") + sample_matrix = models.CharField( + max_length=2, + choices=SampleMatrix.choices, # Use the choices defined in the SampleMatrix class + default=SampleMatrix.GROUNDWATER, # Set a default value for the field. + verbose_name="type of sample" # Human-readable label for user interfaces like forms and the admin panel. + ) sample_date = models.DateTimeField() sample_notes = models.TextField(blank=True, null=True) diff --git a/geodjango/samplelocations/schemas.py b/geodjango/samplelocations/schemas.py new file mode 100644 index 0000000..2616439 --- /dev/null +++ b/geodjango/samplelocations/schemas.py @@ -0,0 +1,60 @@ +from ninja import Schema +from typing import List + +# ========== General Schemas ========== + +class NotFoundSchema(Schema): + detail: str + +class GeoJSONGeometry(Schema): + """ + Geometry schema for GeoJSON response. + """ + + type: str # e.g., "Point", "LineString", "Polygon" + coordinates: ( + List[float] | List[List[float]] | List[List[List[float]]] + ) # Supports Point, LineString, Polygon, etc. + + +# ========== Thing Schemas ========== + +class Feature(Schema): + type: str = "Feature" + geometry: GeoJSONGeometry + +class BaseProperties(Schema): + thing_id: int + name: str + thing_type: str + release_status: bool + date_created: str + + +class WellProperties(BaseProperties): + well_depth_ft: float | None = None + hole_depth_ft: float | None = None + casing_diameter_ft: float | None = None + casing_depth_ft: float | None = None + casing_description: str | None = None + construction_notes: str | None = None + +class SpringProperties(BaseProperties): + spring_type: str | None = None + +class WellFeature(Feature): + properties: WellProperties + +class SpringFeature(Feature): + properties: SpringProperties + +class FeatureCollection(Schema): + type: str = "FeatureCollection" + features: List = [] # can be WellFeature or SpringFeature. Specifying a union of both types makes the schema include unrelated fields, which is not desired. + +# ========== Location Schemas ========== + +class LocationSchema(Schema): + location_id: int + coordinates: str + date_created: str \ No newline at end of file diff --git a/geodjango/samplelocations/tests.py b/geodjango/samplelocations/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/geodjango/samplelocations/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/geodjango/samplelocations/tests/__init__.py b/geodjango/samplelocations/tests/__init__.py new file mode 100644 index 0000000..d1b97ff --- /dev/null +++ b/geodjango/samplelocations/tests/__init__.py @@ -0,0 +1,15 @@ +from django.test import TestCase +from ninja.testing import TestClient +from geodjango.api import api + + +class BaseTestClass(TestCase): + """ + Base class for all test cases. + This class can be used to set up common fixtures or configurations + that are shared across multiple test cases. + It can also be used to define common methods that can be reused + in all test cases. + """ + + client = TestClient(api) \ No newline at end of file diff --git a/geodjango/samplelocations/tests/not_tested/__init__.py b/geodjango/samplelocations/tests/not_tested/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/not_tested/test_asset.py b/geodjango/samplelocations/tests/not_tested/test_asset.py new file mode 100644 index 0000000..65b9ba7 --- /dev/null +++ b/geodjango/samplelocations/tests/not_tested/test_asset.py @@ -0,0 +1,3 @@ +""" +Not in MVP Dreama (as of 2025-07-09) +""" \ No newline at end of file diff --git a/geodjango/samplelocations/tests/not_tested/test_chemistry.py b/geodjango/samplelocations/tests/not_tested/test_chemistry.py new file mode 100644 index 0000000..65b9ba7 --- /dev/null +++ b/geodjango/samplelocations/tests/not_tested/test_chemistry.py @@ -0,0 +1,3 @@ +""" +Not in MVP Dreama (as of 2025-07-09) +""" \ No newline at end of file diff --git a/geodjango/samplelocations/tests/not_tested/test_collabnet.py b/geodjango/samplelocations/tests/not_tested/test_collabnet.py new file mode 100644 index 0000000..65b9ba7 --- /dev/null +++ b/geodjango/samplelocations/tests/not_tested/test_collabnet.py @@ -0,0 +1,3 @@ +""" +Not in MVP Dreama (as of 2025-07-09) +""" \ No newline at end of file diff --git a/geodjango/samplelocations/tests/not_tested/test_form.py b/geodjango/samplelocations/tests/not_tested/test_form.py new file mode 100644 index 0000000..65b9ba7 --- /dev/null +++ b/geodjango/samplelocations/tests/not_tested/test_form.py @@ -0,0 +1,3 @@ +""" +Not in MVP Dreama (as of 2025-07-09) +""" \ No newline at end of file diff --git a/geodjango/samplelocations/tests/not_tested/test_geospatial.py b/geodjango/samplelocations/tests/not_tested/test_geospatial.py new file mode 100644 index 0000000..badccb3 --- /dev/null +++ b/geodjango/samplelocations/tests/not_tested/test_geospatial.py @@ -0,0 +1,3 @@ +""" +These tests belong in test_location.py +""" \ No newline at end of file diff --git a/geodjango/samplelocations/tests/not_tested/test_geothermal.py b/geodjango/samplelocations/tests/not_tested/test_geothermal.py new file mode 100644 index 0000000..63ccaa3 --- /dev/null +++ b/geodjango/samplelocations/tests/not_tested/test_geothermal.py @@ -0,0 +1,3 @@ +""" +All geothermal tests are skipped in SQL Alchemy POC +""" \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_contact.py b/geodjango/samplelocations/tests/test_contact.py new file mode 100644 index 0000000..e08ab13 --- /dev/null +++ b/geodjango/samplelocations/tests/test_contact.py @@ -0,0 +1,157 @@ +from samplelocations.tests import BaseTestClass +from samplelocations.models import Thing + +# ADD tests ====================================================== + + +class TestAddContact(BaseTestClass): + """ + Test cases for adding contacts. + """ + + def setUp(self): + super().setUp() + # Create a Thing instance for use in each test + self.thing = Thing.objects.create( + name="Test Thing", + description="A thing for testing", + ) + + def tearDown(self): + return super().tearDown() + + def test_add_contact(self): + response = self.client.post( + "/contact", + json={ + "name": "Test Contact", + "role": "Owner", + "thing_id": self.thing.thing_id, + "emails": [{"email": "test@example.com", "email_type": "Primary"}], + "phones": [{"phone_number": "+12345678901", "phone_type": "Primary"}], + "addresses": [ + { + "address_line_1": "123 Main St", + "city": "Test City", + "state": "NM", + "postal_code": "87501", + "country": "US", + "address_type": "Primary", + } + ], + }, + ) + data = response.json() + self.assertEqual(response.status_code, 200) + self.assertIn("id", data) + self.assertEqual(data["name"], "Test Contact") + self.assertEqual(data["role"], "Owner") + + self.assertEqual(len(data["emails"]), 1) + self.assertEqual(data["emails"][0]["email"], "test@example.com") + self.assertIn("id", data) + self.assertEqual(data["name"], "Test Contact") + self.assertEqual(data["role"], "Owner") + + self.assertEqual(len(data["emails"]), 1) + self.assertEqual(data["emails"][0]["email"], "test@example.com") + + self.assertEqual(len(data["phones"]), 1) + self.assertEqual(data["phones"][0]["phone_number"], "+12345678901") + self.assertEqual(len(data["addresses"]), 1) + self.assertEqual(data["addresses"][0]["address_line_1"], "123 Main St") + + + def test_phone_validation_fail(self): + for phone in [ + "definitely not a phone", + # "1234567890", + # "123-456-7890", + # "123-456-78901", + # "123-4567-890", + "123-456-789a", + "123-456-7890x1234", + "123.456.7890", + "(123) 456-7890", + ]: + + response = self.client.post( + "/contact", + json={ + "name": "Test Contact 2", + "thing_id": self.thing.thing_id, + "role": "Primary", + "emails": [{"email": "fasdfasdf@gmail.com", "email_type": "Primary"}], + "phones": [{"phone_number": phone, "phone_type": "Primary"}], + "addresses": [ + { + "address_line_1": "123 Main St", + "city": "Test City", + "state": "NM", + "postal_code": "87501", + "country": "US", + "address_type": "Primary", + } + ], + }, + ) + data = response.json() + self.assertEqual(response.status_code, 422) + self.assertIn("detail", data, "Expected 'detail' in response") + self.assertEqual(len(data["detail"]), 1, "Expected 1 error in response") + detail = data["detail"][0] + self.assertEqual(detail["msg"], f"Value error, Invalid phone number. {phone}") + + + def test_email_validation_fail(self): + + for email in [ + "", + "invalid-email", + "invalid@domain", + "invalid@domain.", + "@domain.com", + ]: + response = self.client.post( + "/contact", + json={ + "name": "Test ContactX", + "thing_id": self.thing.thing_id, + "role": "Primary", + "emails": [{"email": email, "email_type": "Primary"}], + "phones": [{"phone_number": "+12345678901", "phone_type": "Primary"}], + "addresses": [ + { + "address_line_1": "123 Main St", + "city": "Test City", + "state": "NM", + "postal_code": "87501", + "country": "US", + "address_type": "Primary", + } + ], + }, + ) + data = response.json() + self.assertEqual(response.status_code, 422) + self.assertIn("detail", data, "Expected 'detail' in response") + self.assertEqual(len(data["detail"]), 1, "Expected 1 error in response") + detail = data["detail"][0] + self.assertEqual(detail["msg"], f"Value error, Invalid email format. {email}") + + +# GET tests ====================================================== + +class TestGetContact(BaseTestClass): + + def test_get_contacts(self): + response = self.client.get("/contact") + self.assertEqual(response.status_code, 200) + self.assertGreater(len(response.json()), 0) + + def test_item_get_contact(self): + response = self.client.get("/contact/1") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], 1) + self.assertEqual(data["name"], "Test Contact") \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_geochronology.py b/geodjango/samplelocations/tests/test_geochronology.py new file mode 100644 index 0000000..6263d33 --- /dev/null +++ b/geodjango/samplelocations/tests/test_geochronology.py @@ -0,0 +1,31 @@ +from samplelocations.tests import BaseTestClass +from samplelocations.models import Thing, Location, Geochronology + +class TestAddGeochronology(BaseTestClass): + + def setUp(self): + super().setUp() + # Create a Thing instance for use in each test + self.thing = self.Thing.objects.create(name="Test Thing", description="A thing for testing") + # Create a Location instance for use in each test + self.location = Location.objects.create(name="Test Location", description="A location for testing") + + def tearDown(self): + return super().tearDown() + + def test_add_age(self): + + response = self.client.post( + "/geochronology", + json={ + "location_id": self.location.location_id, + "age": 100, + "age_unit": "Ma", + "thing_id": self.thing.thing_id, + }, + ) + data = response.json() + self.assertEqual(response.status_code, 200) + self.assertIn("id", data) + self.assertEqual(data["age"], 100) + self.assertEqual(data["age_unit"], "Ma") \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_group.py b/geodjango/samplelocations/tests/test_group.py new file mode 100644 index 0000000..01ca91a --- /dev/null +++ b/geodjango/samplelocations/tests/test_group.py @@ -0,0 +1,69 @@ +from samplelocations.tests import BaseTestClass + + +class TestAddGroup(BaseTestClass): + + def setUp(self): + super().setUp() + # Create a Thing instance for use in each test + self.thing = self.Thing.objects.create( + name="Test Thing", + description="A thing for testing", + ) + + def tearDown(self): + return super().tearDown() + + def test_add_group(self): + response = self.client.post( + "/group", + json={"name": "Test Group"}, + ) + data = response.json() + self.assertEqual(response.status_code, 201) + self.assertIn("id", data) + self.assertEqual(data["name"], "Test Group") + + + def test_add_group_thing(self): + response = self.client.post( + "/group/association", + json={"group_id": 1, "thing_id": self.thing.id}, + ) + data = response.json() + self.assertEqual(response.status_code, 201) + self.assertIn("id", data) + self.assertEqual(data["group_id"], 1) + self.assertEqual(data["thing_id"], self.thing.id) + + +# GET tests ====================================================== + +class TestGetGroup(BaseTestClass): + + def setUp(self): + super().setUp() + # Create a Group instance for use in each test + self.group = self.Group.objects.create(name="Test Group") + + def tearDown(self): + return super().tearDown() + + def test_get_groups(self): + response = self.client.get("/group") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertGreater(len(data), 0) # Assuming there are groups in the database + + def test_get_group_by_id(self): + response = self.client.get(f"/group/{self.group.id}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(data["id"], self.group.id) + self.assertEqual(data["name"], self.group.name) + + def test_get_group_association(self): + response = self.client.get(f"/group/association/{self.group.id}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertGreater(len(data), 0) \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_lexicon.py b/geodjango/samplelocations/tests/test_lexicon.py new file mode 100644 index 0000000..5af0075 --- /dev/null +++ b/geodjango/samplelocations/tests/test_lexicon.py @@ -0,0 +1,88 @@ +from samplelocations.tests import BaseTestClass +from samplelocations.models import Lexicon + +class TestAddLexicon(BaseTestClass): + """ + Test cases for adding lexicon categories and terms. + """ + + def setUp(self): + super().setUp() + + def tearDown(self): + return super().tearDown() + + def test_add_lexicon_category(self): + name = "Test Category" + description = "This is a test category." + + response = self.client.post( + "/lexicon/category/add", + json={"name": name, "description": description}, + ) + + data = response.json() + self.assertEqual(response.status_code, 201) + self.assertEqual(data["name"], name) + self.assertEqual(data["description"], description) + + + def test_add_lexicon_term(self): + term = "test_term" + definition = "This is a test definition." + category = "Test Category" + + response = self.client.post( + "/lexicon/add", + json={"term": term, "definition": definition, "category": category}, + ) + + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data["term"], term) + self.assertEqual(data["definition"], definition) + + def test_add_triple(self): + subject = { + "term": "MG-030", + "definition": "magdalena well", + "category": "location_identifier", + } + predicate = "same_as" + object_ = { + "term": "USGS1234", + "definition": "magdalena well", + "category": "location_identifier", + } + + response = self.client.post( + "/lexicon/triple/add", + json={ + "subject": subject, + "predicate": predicate, + "object_": object_, + }, + ) + + self.assertEqual(response.status_code, 201) + data = response.json() + self.assertEqual(data["subject"], subject["term"]) + self.assertEqual(data["predicate"], predicate) + self.assertEqual(data["object_"], object_["term"]) + +class TestGetLexicon(BaseTestClass): + + def setUp(self): + super().setUp() + # Create a test category + self.category = Lexicon.objects.create( + name="Test Category", + description="A category for testing", + ) + + def tearDown(self): + return super().tearDown() + + def test_get_category(self): + response = self.client.get(f"/lexicon/category/{self.category.name}") + self.assertEqual(response.status_code, 200) \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_location.py b/geodjango/samplelocations/tests/test_location.py new file mode 100644 index 0000000..2435e7c --- /dev/null +++ b/geodjango/samplelocations/tests/test_location.py @@ -0,0 +1,107 @@ +""" +def test_get_geojson(): + response = client.get("/location/feature_collection") + assert response.status_code == 200 + data = response.json() + assert "type" in data + assert data["type"] == "FeatureCollection" + assert "features" in data + assert len(data["features"]) > 0 # Assuming there are features in the collection + + +def test_get_shapefile(): + response = client.get("/location/shapefile") + assert response.status_code == 200 + assert response.headers["Content-Type"] == "application/zip" + assert "Content-Disposition" in response.headers + assert ( + 'attachment; filename="locations.zip"' + == response.headers["Content-Disposition"] + ) +""" + +from samplelocations.tests import BaseTestClass +from samplelocations.models import Location + +# class TestAddLocation(BaseTestClass): + +# def test_add_location_visible_is_true(self): +# response = self.client.post( +# "/location", +# json={ +# "name": "Test Location 1", +# "point": "POINT(10.1 10.1)", +# "visible": True, +# }, +# ) +# self.assertEqual(response.status_code, 201) +# data = response.json() +# self.assertEqual(data["id"], 2) + +# def test_add_location_visible_is_false(self): +# response = self.client.post( +# "/location", +# json={ +# "name": "Test Location 2", +# "point": "POINT(50.0 50.0)", +# "visible": False, +# }, +# ) +# self.assertEqual(response.status_code, 201) +# data = response.json() +# self.assertEqual(data["id"], 3) + + +class TestGetLocation(BaseTestClass): + + def setUp(self): + self.location_1 = Location.objects.create( + coordinate = "POINT(10 10 100)" + ) + + self.location_2 = Location.objects.create( + coordinate = "POINT(20 20 200)", + ) + + def tearDown(self): + self.location_1.delete() + self.location_2.delete() + return super().tearDown() + + def test_get_all_locations(self): + """ + Tests that all locations can be listed + """ + response = self.client.get("/api/location") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data), 2) + self.assertEqual(data[0]["coordinates"], f"POINT({self.location_1.coordinate.x} {self.location_1.coordinate.y} {self.location_1.coordinate.z})") + self.assertEqual(data[1]["coordinates"], f"POINT({self.location_2.coordinate.x} {self.location_2.coordinate.y} {self.location_2.coordinate.z})") + + def test_get_location_by_id(self): + """ + Tests that a specific location can be retrieved by its ID + """ + self.maxDiff = None + response = self.client.get(f"/api/location/{self.location_1.location_id}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual( + data, + { + "location_id": self.location_1.location_id, + "coordinates": f"POINT({self.location_1.coordinate.x} {self.location_1.coordinate.y} {self.location_1.coordinate.z})", + "date_created": self.location_1.date_created.isoformat(), + } + ) + + def test_404_location_not_found(self): + """ + Tests that a 404 is returned when trying to access a non-existent location + """ + response = self.client.get("/api/location/9999") + print(response) + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertEqual(data["detail"], "Location with location_id 9999 not found") \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_mvp.py b/geodjango/samplelocations/tests/test_mvp.py new file mode 100644 index 0000000..8965c96 --- /dev/null +++ b/geodjango/samplelocations/tests/test_mvp.py @@ -0,0 +1,114 @@ +from samplelocations.tests import BaseTestClass + +class TestLocations(BaseTestClass): + + def test_get_locations(self): + response = self.client.get("/api/locations") + self.assertEqual(response.status_code, 200) + + def test_post_location(self): + location = { + "name": "Test Location", + "point": "POINT(10.1 10.1)", + "visible": True, + } + response = self.client.post("/api/locations", json=location) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.json()["id"]) + + +class TestWells(BaseTestClass): + + def test_get_wells(self): + response = self.client.get("/api/wells") + self.assertEqual(response.status_code, 200) + + def test_post_well(self): + well = { + "location_id": 1, + "api_id": "1001-0001", + "ose_pod_id": "RA-0001", + "well_type": "Monitoring", + "well_depth": 100.0, + "hole_depth": 100.0, + "casing_diameter": 10.0, + "casing_depth": 20.0, + "casing_description": "foo bar", + "formation_zone": "San Andres", + "construction_notes": "this is a test of notes", + } + response = self.client.post("/api/wells", json=well) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.json()["id"]) + + + def test_post_well_screen(self): + well_screen = { + "well_id": 1, + "screen_depth_top": 100.0, + "screen_depth_bottom": 120.0, + "screen_type": "PVC", + } + response = self.client.post("/api/wells/well-screens/", json=well_screen) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.json()["id"]) + + +class TestContacts(BaseTestClass): + + def test_post_contact(self): + contact = { + "well_id": 1, + "name": "John Doe", + "email": "foo@gmail.com", + } + response = self.client.post("/api/wells/contacts/", json=contact) + self.assertEqual(response.status_code, 200) + self.assertIsNotNone(response.json()["id"]) + +# # ============== optional ? ============= +# def test_add_lexicon(): +# formation = { +# "term": "San Andres", +# "definition": "Some sandstone unit", +# "category": "Formations", +# } + +# unit = { +# "term": "TDS", +# "definition": "Total Dissolved Solids", +# "category": "water_chemistry", +# } + + +# def test_add_lexicon_triple(): +# subject = { +# "term": "MG-030", +# "definition": "magdalena well", +# "category": "location_identifier", +# } +# predicate = "same_as" +# object_ = { +# "term": "USGS1234", +# "definition": "magdalena well", +# "category": "location_identifier", +# } + + +# def test_add_lexicon_triple_existing_subject(): +# subject = "TDS" +# predicate = "same_as" +# object_ = { +# "term": "Total Dissolved Solids", +# "definition": "all the solids dissolved in sample", +# "category": "water_chemistry", +# } + + +# def test_add_lexicon_triple_existing(): +# subject = "TDS" +# predicate = "same_as" +# object_ = "Total Dissolved Solids" + + +# ============= EOF ============================================= \ No newline at end of file diff --git a/geodjango/samplelocations/tests/test_observation.py b/geodjango/samplelocations/tests/test_observation.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_publication.py b/geodjango/samplelocations/tests/test_publication.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_query.py b/geodjango/samplelocations/tests/test_query.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_regex.py b/geodjango/samplelocations/tests/test_regex.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_sample.py b/geodjango/samplelocations/tests/test_sample.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_search.py b/geodjango/samplelocations/tests/test_search.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_sensor.py b/geodjango/samplelocations/tests/test_sensor.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_series.py b/geodjango/samplelocations/tests/test_series.py new file mode 100644 index 0000000..e69de29 diff --git a/geodjango/samplelocations/tests/test_thing.py b/geodjango/samplelocations/tests/test_thing.py new file mode 100644 index 0000000..0c1556d --- /dev/null +++ b/geodjango/samplelocations/tests/test_thing.py @@ -0,0 +1,167 @@ +from samplelocations.tests import BaseTestClass +from samplelocations.models import Thing +from samplelocations.models import Location, Location_Thing_Junction +from pprint import pprint + +class TestThing(BaseTestClass): + """ + Test cases for the Thing model. + """ + + maxDiff = None + + def setUp(self): + super().setUp() + + # Create Location records + self.location1 = Location.objects.create( + coordinate="POINT(10.0 10.0 100.0)", + + ) + self.location2 = Location.objects.create( + coordinate="POINT(20.0 20.0 200.0)", + ) + + # Create Thing records + self.well_thing = Thing.objects.create( + name="Test Well", + thing_type="W", + release_status=True, + well_depth_ft=100.0, + hole_depth_ft=120.0, + casing_diameter_ft=10.0, + casing_depth_ft=80.0, + casing_description="PVC", + construction_notes="Test well construction notes", + ) + self.spring_thing = Thing.objects.create( + name="Test Spring", + thing_type="S", + release_status=True, + spring_type="thermal", + ) + + # Create Location_Thing_Junction records + self.junction1 = Location_Thing_Junction.objects.create( + location_id=self.location1, + thing_id=self.well_thing, + effective_start="2023-10-01T00:00:00Z", + effective_end="2040-01-01T00:00:00Z", # Assuming a future end date for the test + ) + self.junction2 = Location_Thing_Junction.objects.create( + location_id=self.location2, + thing_id=self.spring_thing, + effective_start="2023-10-01T00:00:00Z", + effective_end="2040-01-01T00:00:00Z", # Assuming a future end date for the test + ) + + # Assign locations using the ManyToManyField + self.well_thing.location_id.set([self.location1]) + self.spring_thing.location_id.set([self.location2]) + + def tearDown(self): + self.junction1.delete() + self.junction2.delete() + self.well_thing.delete() + self.spring_thing.delete() + self.location1.delete() + self.location2.delete() + return super().tearDown() + + def test_get_all_things(self): + """ + List all things in the database as a feature collection + """ + response = self.client.get("/api/thing") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual(len(data["features"]), 2) + self.assertEqual( + data, + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [10.0, 10.0, 100.0] + }, + "properties": { + "thing_id": self.well_thing.thing_id, + "name": self.well_thing.name, + "thing_type": "Well", + "release_status": self.well_thing.release_status, + "date_created": self.well_thing.date_created.isoformat(), + "well_depth_ft": self.well_thing.well_depth_ft, + "hole_depth_ft": self.well_thing.hole_depth_ft, + "casing_diameter_ft": self.well_thing.casing_diameter_ft, + "casing_depth_ft": self.well_thing.casing_depth_ft, + "casing_description": self.well_thing.casing_description, + "construction_notes": self.well_thing.construction_notes, + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [20.0, 20.0, 200.0] + }, + "properties": { + "thing_id": self.spring_thing.thing_id, + "name": self.spring_thing.name, + "thing_type": "Spring", + "release_status": self.spring_thing.release_status, + "date_created": self.spring_thing.date_created.isoformat(), + "spring_type": self.spring_thing.spring_type, + } + } + ] + } + ) + + def test_get_thing_by_id(self): + """ + Retrieve a specific thing by its ID as a feature collection + """ + thing_id = self.well_thing.thing_id + response = self.client.get(f"/api/thing/{thing_id}") + self.assertEqual(response.status_code, 200) + data = response.json() + self.assertEqual( + data, + { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [10.0, 10.0, 100.0] + }, + "properties": { + "thing_id": self.well_thing.thing_id, + "name": self.well_thing.name, + "thing_type": "Well", + "release_status": self.well_thing.release_status, + "date_created": self.well_thing.date_created.isoformat(), + "well_depth_ft": self.well_thing.well_depth_ft, + "hole_depth_ft": self.well_thing.hole_depth_ft, + "casing_diameter_ft": self.well_thing.casing_diameter_ft, + "casing_depth_ft": self.well_thing.casing_depth_ft, + "casing_description": self.well_thing.casing_description, + "construction_notes": self.well_thing.construction_notes, + } + }, + ] + } + ) + + def test_404_not_found(self): + """ + Test that a 404 is returned for a non-existent thing ID + """ + response = self.client.get("/api/thing/9999") + self.assertEqual(response.status_code, 404) + data = response.json() + self.assertEqual(data["detail"], "Thing with id 9999 not found")