diff --git a/.gitignore b/.gitignore index e43b0f9..783b798 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ .DS_Store +/**/*.shp +/**/*.shx diff --git a/bats_ai/core/migrations/0006_alter_recording_recording_location.py b/bats_ai/core/migrations/0006_alter_recording_recording_location.py new file mode 100644 index 0000000..89fb84e --- /dev/null +++ b/bats_ai/core/migrations/0006_alter_recording_recording_location.py @@ -0,0 +1,20 @@ +# Generated by Django 4.1.13 on 2024-02-02 16:19 + +import django.contrib.gis.db.models.fields +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ('core', '0005_recording_public'), + ] + + operations = [ + migrations.AlterField( + model_name='recording', + name='recording_location', + field=django.contrib.gis.db.models.fields.GeometryField( + blank=True, null=True, srid=4326 + ), + ), + ] diff --git a/bats_ai/core/models/recording.py b/bats_ai/core/models/recording.py index 01fca79..52af03e 100644 --- a/bats_ai/core/models/recording.py +++ b/bats_ai/core/models/recording.py @@ -11,7 +11,7 @@ class Recording(TimeStampedModel, models.Model): recorded_date = models.DateField(blank=True, null=True) equipment = models.TextField(blank=True, null=True) comments = models.TextField(blank=True, null=True) - recording_location = models.GeometryField(srid=0, blank=True, null=True) + recording_location = models.GeometryField(srid=4326, blank=True, null=True) grts_cell_id = models.IntegerField(blank=True, null=True) grts_cell = models.IntegerField(blank=True, null=True) public = models.BooleanField(default=False) diff --git a/bats_ai/core/views/recording.py b/bats_ai/core/views/recording.py index 951c042..4fc9323 100644 --- a/bats_ai/core/views/recording.py +++ b/bats_ai/core/views/recording.py @@ -1,7 +1,9 @@ from datetime import datetime +import json import logging from django.contrib.auth.models import User +from django.contrib.gis.geos import Point from django.core.files.storage import default_storage from django.http import HttpRequest from ninja import File, Form, Schema @@ -32,8 +34,11 @@ class RecordingSchema(Schema): class RecordingUploadSchema(Schema): name: str recorded_date: str - equipment: str = None - comments: str = None + equipment: str | None + comments: str | None + latitude: float = None + longitude: float = None + gridCellId: int = None publicVal: bool = None @@ -79,17 +84,21 @@ def create_recording( publicVal: bool = False, ): converted_date = datetime.strptime(payload.recorded_date, '%Y-%m-%d') + point = None + if payload.latitude and payload.longitude: + point = Point(payload.longitude, payload.latitude) recording = Recording( name=payload.name, owner_id=request.user.pk, audio_file=audio_file, recorded_date=converted_date, equipment=payload.equipment, + grts_cell_id=payload.gridCellId, + recording_location=point, public=publicVal, comments=payload.comments, ) recording.save() - return {'message': 'Recording updated successfully', 'id': recording.pk} @@ -111,6 +120,9 @@ def update_recording(request: HttpRequest, id: int, recording_data: RecordingUpl recording.recorded_date = converted_date if recording_data.publicVal is not None and recording_data.publicVal != recording.public: recording.public = recording_data.publicVal + if recording_data.latitude and recording_data.longitude: + point = Point(recording_data.longitude, recording_data.latitude) + recording.recording_location = point recording.save() @@ -125,10 +137,13 @@ def get_recordings(request: HttpRequest, public: bool | None = None): else: recordings = Recording.objects.filter(owner=request.user).values() + # TODO with larger dataset it may be better to do this in a queryset instead of python for recording in recordings: user = User.objects.get(id=recording['owner_id']) recording['owner_username'] = user.username recording['audio_file_presigned_url'] = default_storage.url(recording['audio_file']) + if recording['recording_location']: + recording['recording_location'] = json.loads(recording['recording_location'].json) unique_users_with_annotations = ( Annotations.objects.filter(recording_id=recording['id']) .values('owner') @@ -141,7 +156,6 @@ def get_recordings(request: HttpRequest, public: bool | None = None): ).exists() recording['userMadeAnnotations'] = user_has_annotations - # Return the serialized data return list(recordings) diff --git a/client/src/api/api.ts b/client/src/api/api.ts index 21de0e8..5a0be0c 100644 --- a/client/src/api/api.ts +++ b/client/src/api/api.ts @@ -20,7 +20,7 @@ export interface Recording { recorded_date: string; equipment?: string, comments?: string; - recording_location?: null | [number, number], + recording_location?: null | GeoJSON.Point, grts_cell_id?: null | number; grts_cell?: null | number; public: boolean; @@ -94,20 +94,29 @@ interface PaginatedNinjaResponse { items: T[], } - +export type UploadLocation = null | { latitude?: number, longitude?: number, gridCellId?: number}; export const axiosInstance = axios.create({ baseURL: import.meta.env.VUE_APP_API_ROOT as string, }); -async function uploadRecordingFile(file: File, name: string, recorded_date: string, equipment: string, comments: string, publicVal = false ) { +async function uploadRecordingFile(file: File, name: string, recorded_date: string, equipment: string, comments: string, publicVal = false, location: UploadLocation = null ) { const formData = new FormData(); formData.append('audio_file', file); formData.append('name', name); formData.append('recorded_date', recorded_date); formData.append('equipment', equipment); formData.append('comments', comments); + if (location) { + if (location.latitude && location.longitude) { + formData.append('latitude', location.latitude.toString()); + formData.append('longitude', location.longitude.toString()); + } + if (location.gridCellId) { + formData.append('gridCellId', location.gridCellId.toString()); + } + } const recordingParams = { name, @@ -126,14 +135,21 @@ async function uploadRecordingFile(file: File, name: string, recorded_date: stri }); } - async function patchRecording(recordingId: number, name: string, recorded_date: string, equipment: string, comments: string, publicVal = false ) { + async function patchRecording(recordingId: number, name: string, recorded_date: string, equipment: string, comments: string, publicVal = false, location: UploadLocation = null ) { + const latitude = location ? location.latitude : undefined; + const longitude = location ? location.longitude : undefined; + const gridCellId = location ? location.gridCellId : undefined; + await axiosInstance.patch(`/recording/${recordingId}`, { name, recorded_date, equipment, comments, - publicVal + publicVal, + latitude, + longitude, + gridCellId }, { headers: { diff --git a/client/src/components/MapLocation.vue b/client/src/components/MapLocation.vue new file mode 100644 index 0000000..41e910e --- /dev/null +++ b/client/src/components/MapLocation.vue @@ -0,0 +1,124 @@ + + + + + + diff --git a/client/src/components/UploadRecording.vue b/client/src/components/UploadRecording.vue index d9753db..8f71304 100644 --- a/client/src/components/UploadRecording.vue +++ b/client/src/components/UploadRecording.vue @@ -2,9 +2,9 @@ import { defineComponent, PropType, ref, Ref } from 'vue'; import { RecordingMimeTypes } from '../constants'; import useRequest from '../use/useRequest'; -import { patchRecording, uploadRecordingFile } from '../api/api'; +import { UploadLocation, uploadRecordingFile, patchRecording } from '../api/api'; import { VDatePicker } from 'vuetify/labs/VDatePicker'; - +import MapLocation from './MapLocation.vue'; export interface EditingRecording { id: number, name: string, @@ -12,10 +12,12 @@ export interface EditingRecording { equipment: string, comments: string, public: boolean; + location?: { lat: number, lon: number }, } export default defineComponent({ components: { VDatePicker, + MapLocation, }, props: { editing: { @@ -36,6 +38,9 @@ export default defineComponent({ const equipment = ref(props.editing ? props.editing.equipment : ''); const comments = ref(props.editing ? props.editing.comments : ''); const validForm = ref(false); + const latitude: Ref = ref(props.editing?.location?.lat ? props.editing.location.lat : undefined); + const longitude: Ref = ref(props.editing?.location?.lon ? props.editing.location.lon : undefined); + const gridCellId: Ref = ref(); const publicVal = ref(props.editing ? props.editing.public : false); const readFile = (e: Event) => { const target = (e.target as HTMLInputElement); @@ -63,13 +68,39 @@ export default defineComponent({ if (!file) { throw new Error('Unreachable'); } - await uploadRecordingFile(file, name.value, recordedDate.value, equipment.value, comments.value, publicVal.value); + let location: UploadLocation = null; + if (latitude.value && longitude.value) { + location = { + latitude: latitude.value, + longitude: longitude.value, + }; + } + if (gridCellId.value !== null) { + if (location === null) { + location = {}; + } + location['gridCellId'] = gridCellId.value; + } + await uploadRecordingFile(file, name.value, recordedDate.value, equipment.value, comments.value, publicVal.value, location); emit('done'); }); const handleSubmit = async () => { if (props.editing) { - await patchRecording(props.editing.id, name.value, recordedDate.value, equipment.value, comments.value, publicVal.value); + let location: UploadLocation = null; + if (latitude.value && longitude.value) { + location = { + latitude: latitude.value, + longitude: longitude.value, + }; + } + if (gridCellId.value !== null) { + if (location === null) { + location = {}; + } + location['gridCellId'] = gridCellId.value; + } + await patchRecording(props.editing.id, name.value, recordedDate.value, equipment.value, comments.value, publicVal.value, location); emit('done'); } else { submit(); @@ -81,6 +112,15 @@ export default defineComponent({ recordedDate.value = new Date(time as string).toISOString().split('T')[0]; }; + const setLocation = ({lat, lon}: {lat: number, lon: number}) => { + latitude.value = lat; + longitude.value = lon; + }; + + const updateMap = ref(0); // updates the map when lat/lon change by editing directly; + + const triggerUpdateMap = () => updateMap.value += 1; + return { errorText, fileModel, @@ -94,11 +134,17 @@ export default defineComponent({ comments, recordedDate, validForm, + latitude, + longitude, + gridCellId, publicVal, + updateMap, selectFile, readFile, handleSubmit, updateTime, + setLocation, + triggerUpdateMap, }; }, }); @@ -126,7 +172,7 @@ export default defineComponent({ > @@ -215,6 +261,44 @@ export default defineComponent({ + + Location + + + + + + + + + + + + + + + Details diff --git a/client/src/views/Recordings.vue b/client/src/views/Recordings.vue index 13b0149..37bc374 100644 --- a/client/src/views/Recordings.vue +++ b/client/src/views/Recordings.vue @@ -5,11 +5,13 @@ import { VDataTable, } from "vuetify/labs/VDataTable"; import UploadRecording, { EditingRecording } from '../components/UploadRecording.vue'; +import MapLocation from '../components/MapLocation.vue'; export default defineComponent({ components: { VDataTable, UploadRecording, + MapLocation, }, setup() { const itemsPerPage = ref(-1); @@ -40,7 +42,10 @@ export default defineComponent({ title:'Public', key:'public', }, - + { + title: 'Location', + key:'recording_location' + }, { title:'Equipment', key:'equipment', @@ -72,7 +77,10 @@ export default defineComponent({ title:'Public', key:'public', }, - + { + title: 'Location', + key:'recording_location' + }, { title:'Equipment', key:'equipment', @@ -110,6 +118,10 @@ export default defineComponent({ public: item.public, id: item.id, }; + if (item.recording_location) { + const [ lat, lon ] = item.recording_location.coordinates; + editingRecording.value['location'] = {lat, lon}; + } uploadDialog.value = true; }; @@ -165,6 +177,32 @@ export default defineComponent({ {{ item.raw.name }} + + + +