implement import editing

This commit is contained in:
Cyberes 2024-09-09 17:53:09 -06:00
parent 2d5525ccce
commit 613210beaa
13 changed files with 248 additions and 46 deletions

View File

@ -1 +1,2 @@
Recall
Centerline

View File

@ -1,3 +1,3 @@
Test Accounts:
admin:hei8iWae
admin1:hei8iWae

View File

@ -1,10 +1,11 @@
from django.urls import path
from data.views.import_item import upload_item, fetch_import_queue, fetch_queued, delete_import_queue
from data.views.import_item import upload_item, fetch_import_queue, fetch_queued, delete_import_queue, update_imported_item
urlpatterns = [
path('item/import/upload/', upload_item, name='upload_file'),
path('item/import/get/<int:id>', fetch_import_queue, name='fetch_import_queue'),
path('item/import/get/<int:item_id>', fetch_import_queue, name='fetch_import_queue'),
path('item/import/get/mine', fetch_queued, name='fetch_queued'),
path('item/import/delete/<int:id>', delete_import_queue, name='delete_import_queue'),
path('item/import/update/<int:item_id>', update_imported_item),
]

View File

@ -6,10 +6,14 @@ from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.db import IntegrityError
from django.http import HttpResponse, JsonResponse
from django.views.decorators.csrf import csrf_protect
from django.views.decorators.http import require_http_methods
from data.models import ImportQueue
from geo_lib.daemon.database.locking import DBLockManager
from geo_lib.daemon.workers.workers_lib.importer.kml import kmz_to_kml
from geo_lib.daemon.workers.workers_lib.importer.tagging import generate_auto_tags
from geo_lib.types.feature import GeoPoint, GeoLineString, GeoPolygon
from geo_lib.website.auth import login_required_401
@ -63,12 +67,12 @@ def upload_item(request):
@login_required_401
def fetch_import_queue(request, id):
if id is None:
def fetch_import_queue(request, item_id):
if item_id is None:
return JsonResponse({'success': False, 'msg': 'ID not provided', 'code': 400}, status=400)
lock_manager = DBLockManager()
try:
queue = ImportQueue.objects.get(id=id)
queue = ImportQueue.objects.get(id=item_id)
if queue.user_id != request.user.id:
return JsonResponse({'success': False, 'msg': 'not authorized to view this item', 'code': 403}, status=400)
if not lock_manager.is_locked('data_importqueue', queue.id) and (len(queue.geofeatures) or len(queue.log)):
@ -80,7 +84,7 @@ def fetch_import_queue(request, id):
@login_required_401
def fetch_queued(request):
user_items = ImportQueue.objects.filter(user=request.user).values('id', 'geofeatures', 'original_filename', 'raw_kml_hash', 'data', 'log', 'timestamp')
user_items = ImportQueue.objects.exclude(geofeatures__len=0).filter(user=request.user).values('id', 'geofeatures', 'original_filename', 'raw_kml_hash', 'data', 'log', 'timestamp')
data = json.loads(json.dumps(list(user_items), cls=DjangoJSONEncoder))
lock_manager = DBLockManager()
for i, item in enumerate(data):
@ -103,6 +107,50 @@ def delete_import_queue(request, id):
return HttpResponse(status=405)
@login_required_401
@csrf_protect # TODO: put this on all routes
@require_http_methods(["PUT"])
def update_imported_item(request, item_id):
try:
queue = ImportQueue.objects.get(id=item_id)
except ImportQueue.DoesNotExist:
return JsonResponse({'success': False, 'msg': 'ID does not exist', 'code': 404}, status=400)
if queue.user_id != request.user.id:
return JsonResponse({'success': False, 'msg': 'not authorized to edit this item', 'code': 403}, status=403)
try:
data = json.loads(request.body)
if not isinstance(data, list):
raise ValueError('Invalid data format. Expected a list.')
except (json.JSONDecodeError, ValueError) as e:
return JsonResponse({'success': False, 'msg': str(e), 'code': 400}, status=400)
parsed_data = []
for feature in data:
match feature['type'].lower():
case 'point':
c = GeoPoint(**feature)
case 'linestring':
c = GeoLineString(**feature)
case 'polygon':
c = GeoPolygon(**feature)
case _:
continue
# Generate the tags after the user has made their changes.
c.properties.tags = generate_auto_tags(c)
parsed_data.append(json.loads(c.model_dump_json()))
# Erase the geofeatures column
queue.geofeatures = []
# Update the data column with the new data
queue.data = parsed_data
queue.save()
return JsonResponse({'success': True, 'msg': 'Item updated successfully'})
def _hash_kml(b: str):
if not isinstance(b, bytes):
b = b.encode()

View File

@ -10,7 +10,6 @@ from geo_lib.daemon.database.connection import CursorFromConnectionFromPool
from geo_lib.daemon.database.locking import DBLockManager
from geo_lib.daemon.workers.workers_lib.importer.kml import kml_to_geojson
from geo_lib.daemon.workers.workers_lib.importer.logging import create_import_log_msg
from geo_lib.daemon.workers.workers_lib.importer.tagging import generate_auto_tags
from geo_lib.logging.database import log_to_db, DatabaseLogLevel, DatabaseLogSource
from geo_lib.time import get_time_ms
from geo_lib.types.feature import geojson_to_geofeature
@ -49,8 +48,6 @@ def import_worker():
messages.extend(kml_conv_messages)
geofetures, typing_messages = geojson_to_geofeature(geojson_data)
messages.extend(typing_messages)
for feature in geofetures:
feature.tags = generate_auto_tags(feature)
success = True
except Exception as e:
err_name = e.__class__.__name__

View File

@ -10,6 +10,6 @@ def generate_auto_tags(feature: GeoFeatureSupported) -> List[str]:
]
now = datetime.now()
tags.append(f'year:{now.year}')
tags.append(f'month:{now.strftime("%B")}')
tags.append(f'import-year:{now.year}')
tags.append(f'import-month:{now.strftime("%B")}')
return [str(x) for x in tags]

View File

@ -0,0 +1,2 @@
SOFTWARE_NAME = 'geo-backend'
SOFTWARE_VERSION = '0.0.0'

View File

@ -1,9 +1,12 @@
from datetime import datetime
from enum import Enum
from typing import Optional, List, Union, Tuple
import pytz
from pydantic import Field, BaseModel
from geo_lib.daemon.workers.workers_lib.importer.logging import create_import_log_msg
from geo_lib.geo_backend import SOFTWARE_NAME, SOFTWARE_VERSION
class GeoFeatureType(str, Enum):
@ -12,6 +15,13 @@ class GeoFeatureType(str, Enum):
POLYGON = 'Polygon'
class GeoFeatureProperties(BaseModel):
tags: List[str] = Field(default_factory=list)
created: datetime = datetime.utcnow().replace(tzinfo=pytz.utc)
software: str = Field(SOFTWARE_NAME, frozen=True)
software_version: str = Field(SOFTWARE_VERSION, frozen=True)
class GeoFeature(BaseModel):
"""
A thing that's shown on the map.
@ -21,8 +31,8 @@ class GeoFeature(BaseModel):
id: int # From the database
type: GeoFeatureType
description: Optional[str] = None
tags: List[str] = Field(default_factory=list)
geometry: List
properties: GeoFeatureProperties = Field(default_factory=GeoFeatureProperties)
class GeoPoint(GeoFeature):

View File

@ -8,4 +8,5 @@ geojson==3.1.0
pydantic==2.7.3
sqlalchemy==2.0.30
redis==5.0.5
async_timeout==4.0.3
async_timeout==4.0.3
pytz

View File

@ -11,8 +11,10 @@
"@types/geojson": "^7946.0.14",
"axios": "^1.7.2",
"dropzone-vue": "^0.1.11",
"flatpickr": "^4.6.13",
"geojson": "^0.5.0",
"vue": "^3.4.21",
"vue-flatpickr-component": "^11.0.5",
"vue-router": "^4.3.2",
"vuex": "^4.1.0"
},
@ -943,9 +945,10 @@
}
},
"node_modules/axios": {
"version": "1.7.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz",
"integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==",
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
"integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@ -1330,6 +1333,12 @@
"node": ">=8"
}
},
"node_modules/flatpickr": {
"version": "4.6.13",
"resolved": "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz",
"integrity": "sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
@ -1606,10 +1615,11 @@
}
},
"node_modules/micromatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@ -2434,6 +2444,21 @@
}
}
},
"node_modules/vue-flatpickr-component": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/vue-flatpickr-component/-/vue-flatpickr-component-11.0.5.tgz",
"integrity": "sha512-Vfwg5uVU+sanKkkLzUGC5BUlWd5wlqAMq/UpQ6lI2BCZq0DDrXhOMX7hrevt8bEgglIq2QUv0K2Nl84Me/VnlA==",
"license": "MIT",
"dependencies": {
"flatpickr": "^4.6.13"
},
"engines": {
"node": ">=14.13.0"
},
"peerDependencies": {
"vue": "^3.2.0"
}
},
"node_modules/vue-router": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.2.tgz",

View File

@ -12,8 +12,10 @@
"@types/geojson": "^7946.0.14",
"axios": "^1.7.2",
"dropzone-vue": "^0.1.11",
"flatpickr": "^4.6.13",
"geojson": "^0.5.0",
"vue": "^3.4.21",
"vue-flatpickr-component": "^11.0.5",
"vue-router": "^4.3.2",
"vuex": "^4.1.0"
},

View File

@ -4,13 +4,20 @@ enum GeoFeatureType {
POLYGON = 'Polygon'
}
interface GeoFeatureProperties {
created: Date;
software: string;
software_version: string;
tags: string[];
}
interface GeoFeatureProps {
name: string;
id: number;
type: GeoFeatureType;
description?: string;
tags?: string[];
geometry: any[];
properties: GeoFeatureProperties;
}
class GeoFeature {
@ -20,40 +27,29 @@ class GeoFeature {
description?: string;
tags: string[] = [];
geometry: any[];
properties: GeoFeatureProperties;
constructor(props: GeoFeatureProps) {
this.name = props.name;
this.id = props.id;
this.type = props.type;
this.description = props.description;
this.tags = props.tags || [];
this.geometry = props.geometry || [];
this.properties = props.properties;
}
}
export class GeoPoint extends GeoFeature {
type: GeoFeatureType = GeoFeatureType.POINT;
geometry: number[];
constructor(props: GeoFeatureProps) {
super({...props, type: GeoFeatureType.POINT});
}
}
export class GeoLineString extends GeoFeature {
type: GeoFeatureType = GeoFeatureType.LINESTRING;
geometry: number[][];
constructor(props: GeoFeatureProps) {
super({...props, type: GeoFeatureType.LINESTRING});
}
}
export class GeoPolygon extends GeoFeature {
type: GeoFeatureType = GeoFeatureType.POLYGON;
geometry: number[][][];
constructor(props: GeoFeatureProps) {
super({...props, type: GeoFeatureType.POLYGON});
}
}

View File

@ -11,15 +11,72 @@
<p class="font-bold">{{ item }}</p>
</li>
</div>
<div>
<li v-for="(item, index) in itemsForUser" :key="`item-${index}`">
<h2>{{ item.name }}</h2>
<pre>
{{ item }}
</pre>
</li>
<ul class="space-y-4">
<li v-for="(item, index) in itemsForUser" :key="`item-${index}`" class="bg-white shadow-md rounded-md p-4">
<div class="mb-4">
<label class="block text-gray-700 font-bold mb-2">Name:</label>
<div class="flex items-center">
<input v-model="item.name" :placeholder="originalItems[index].name"
class="border border-gray-300 rounded-md px-3 py-2 w-full"/>
<button class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold py-2 px-4 rounded"
@click="resetField(index, 'name')">Reset
</button>
</div>
</div>
<div class="mb-4">
<label class="block text-gray-700 font-bold mb-2">Description:</label>
<div class="flex items-center">
<input v-model="item.description" :placeholder="originalItems[index].description"
class="border border-gray-300 rounded-md px-3 py-2 w-full"/>
<button class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold py-2 px-4 rounded"
@click="resetField(index, 'description')">Reset
</button>
</div>
</div>
<div class="">
<label class="block text-gray-700 font-bold mb-2">Created:</label>
<div class="flex items-center">
<flat-pickr :config="flatpickrConfig" :value="item.properties.created"
class="border border-gray-300 rounded-md px-3 py-2 w-full"
@on-change="updateDate(index, $event)"></flat-pickr>
<button class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold py-2 px-4 rounded"
@click="resetNestedField(index, 'properties', 'created')">Reset
</button>
</div>
<div>
<label class="block text-gray-700 font-bold mb-2">Tags:</label>
<div v-for="(tag, tagIndex) in item.properties.tags" :key="`tag-${tagIndex}`" class="mb-2">
<div class="flex items-center">
<input v-model="item.properties.tags[tagIndex]" :placeholder="getTagPlaceholder(index, tag)"
class="border rounded-md px-3 py-2 w-full bg-white"/>
<button class="ml-2 bg-red-500 hover:bg-red-600 text-white font-bold py-2 px-4 rounded"
@click="removeTag(index, tagIndex)">Remove
</button>
</div>
</div>
</div>
<div class="flex items-center mt-2">
<button :class="{ 'opacity-50 cursor-not-allowed': isLastTagEmpty(index) }"
:disabled="isLastTagEmpty(index)"
class="bg-blue-500 hover:bg-blue-600 text-white font-bold py-2 px-4 rounded"
@click="addTag(index)">Add Tag
</button>
<button class="ml-2 bg-gray-200 hover:bg-gray-300 text-gray-700 font-bold py-2 px-4 rounded"
@click="resetTags(index)">Reset Tags
</button>
</div>
</div>
</li>
</ul>
</div>
<button class="m-2 bg-green-500 hover:bg-green-600 text-white font-bold py-2 px-4 rounded"
@click="saveChanges">Save
</button>
<div class="hidden">
<!-- Load the queue to populate it. -->
<Importqueue/>
@ -34,6 +91,9 @@ import {capitalizeFirstLetter} from "@/assets/js/string.js";
import Importqueue from "@/components/import/parts/importqueue.vue";
import {GeoFeatureTypeStrings} from "@/assets/js/types/geofeature-strings";
import {GeoPoint, GeoLineString, GeoPolygon} from "@/assets/js/types/geofeature-types";
import {getCookie} from "@/assets/js/auth.js";
import flatPickr from 'vue-flatpickr-component';
import 'flatpickr/dist/flatpickr.css';
// TODO: for each feature, query the DB and check if there is a duplicate. For points that's duplicate coords, for linestrings and polygons that's duplicate points
// TODO: auto-refresh if still processing
@ -42,13 +102,20 @@ export default {
computed: {
...mapState(["userInfo"]),
},
components: {Importqueue},
components: {Importqueue, flatPickr},
data() {
return {
msg: "",
currentId: null,
itemsForUser: [],
workerLog: []
originalItems: [],
workerLog: [],
flatpickrConfig: {
enableTime: true,
time_24hr: true,
dateFormat: 'Y-m-d H:i',
timezone: 'UTC',
},
}
},
mixins: [authMixin],
@ -69,14 +136,63 @@ export default {
default:
throw new Error(`Invalid feature type: ${item.type}`);
}
}
},
},
resetField(index, fieldName) {
this.itemsForUser[index][fieldName] = this.originalItems[index][fieldName];
},
resetNestedField(index, nestedField, fieldName) {
this.itemsForUser[index][nestedField][fieldName] = this.originalItems[index][nestedField][fieldName];
},
addTag(index) {
if (!this.isLastTagEmpty(index)) {
this.itemsForUser[index].tags.push('');
}
},
getTagPlaceholder(index, tag) {
const originalTagIndex = this.originalItems[index].tags.indexOf(tag);
return originalTagIndex !== -1 ? this.originalItems[index].tags[originalTagIndex] : '';
},
isLastTagEmpty(index) {
const tags = this.itemsForUser[index].tags;
return tags.length > 0 && tags[tags.length - 1].trim().length === 0;
},
resetTags(index) {
this.itemsForUser[index].tags = [...this.originalItems[index].tags];
},
removeTag(index, tagIndex) {
this.itemsForUser[index].tags.splice(tagIndex, 1);
},
updateDate(index, selectedDates) {
this.itemsForUser[index].properties.created = selectedDates[0];
},
saveChanges() {
const csrftoken = getCookie('csrftoken');
axios.put('/api/data/item/import/update/' + this.id, this.itemsForUser, {
headers: {
'X-CSRFToken': csrftoken
}
}).then(response => {
if (response.data.success) {
this.msg = 'Changes saved successfully';
window.alert(this.msg);
} else {
this.msg = 'Error saving changes: ' + response.data.msg;
window.alert(this.msg);
}
}).catch(error => {
this.msg = 'Error saving changes: ' + error.message;
window.alert(this.msg);
});
},
}
,
beforeRouteEnter(to, from, next) {
next(async vm => {
if (vm.currentId !== vm.id) {
vm.msg = ""
vm.messages = []
vm.itemsForUser = []
vm.originalItems = []
vm.currentId = null
axios.get('/api/data/item/import/get/' + vm.id).then(response => {
if (!response.data.success) {
@ -87,6 +203,7 @@ export default {
response.data.geofeatures.forEach((item) => {
vm.itemsForUser.push(vm.parseGeoJson(item))
})
vm.originalItems = JSON.parse(JSON.stringify(vm.itemsForUser))
}
vm.msg = response.data.msg
vm.workerLog = response.data.log
@ -96,8 +213,10 @@ export default {
})
}
})
},
};
}
,
}
</script>
<style scoped>