add file uploading

This commit is contained in:
Cyberes 2024-06-08 21:18:20 -06:00
parent e767ee1006
commit b76f0ee951
45 changed files with 2216 additions and 209 deletions

View File

@ -2,4 +2,14 @@
*Service to organize unified spatial data.*
**Name is pending.**
This project is similar to [GeoNetwork](https://www.geonetwork-opensource.org): a spatial information management environment. But where GeoNetwork is designed for cataloging geospatial data into discrete datasets for professional and academic projects, *geoserver* is used by an individual to store, organize, and share their personal spatial information on an easy to use and self-hosted platform.
**This platform does not support editing.** Use your own preferred tool and then upload your data to the server.
Name is pending.

22
ideas.txt Normal file
View File

@ -0,0 +1,22 @@
Django backend, vue.js frontend
Tagging support (tag roads, trails, etc)
Sharing (share individual items or select items or tags to include)
Organization by folder
When updating items, create a new version
No spatial editing
Spatial statistics
Track replay and stats
When importing, list all new items and make it quick and easy to edit the attributes of new items
Visibility: private, public, restricted (only logged in users)
Save the unmodified original imported file in a special table and link it to the item ID
## Stretch Goals
Additional map layers
Option to add external custom layers
Ability to set layer when sharing web map
Geotagged photo support (set path to scan)
Group support and share to group
Some way to record and push to server from an app. Use GPSLogger with %ALL
Download all data as KML
A very simple Android app to Make it easy to push data to the server. For example, export from back country navigator, share to this app, import to server using app

1
names.txt Normal file
View File

@ -0,0 +1 @@
Recall

View File

@ -0,0 +1,8 @@
class CustomHeaderMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request):
response = self.get_response(request)
response['Access-Control-Allow-Origin'] = '*'
return response

View File

@ -46,6 +46,7 @@ MIDDLEWARE = [
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'geo_backend.middleware.CustomHeaderMiddleware',
]
ROOT_URLCONF = 'geo_backend.urls'
@ -129,3 +130,5 @@ LOGOUT_REDIRECT_URL = '/#/'
STATICFILES_DIRS = [
os.path.join(BASE_DIR, '../geo-frontend/dist/static'),
]
APPEND_SLASH = False

View File

@ -16,11 +16,13 @@ Including another URLconf
"""
from django.conf.urls import include
from django.contrib import admin
from django.urls import re_path, path
from django.urls import path, re_path
from geo_backend import views
from geo_backend.views import index
urlpatterns = [
path('', include("users.urls")),
path('', index),
re_path(r"^account/", include("django.contrib.auth.urls")),
path('admin/', admin.site.urls),
path('', include("users.urls")),
]

View File

@ -1 +1,5 @@
from django.shortcuts import render
def index(request):
return render(request, "index.html")

View File

View File

@ -0,0 +1,58 @@
import io
import re
import zipfile
from fastkml import kml
from shapely.geometry import mapping, shape
# TODO: preserve KML object styling, such as color and opacity
def kml_to_geojson(kml_bytes):
try:
# Try to open as a zipfile (KMZ)
with zipfile.ZipFile(io.BytesIO(kml_bytes), 'r') as kmz:
# Find the first .kml file in the zipfile
kml_file = [name for name in kmz.namelist() if name.endswith('.kml')][0]
doc = kmz.read(kml_file).decode('utf-8')
except zipfile.BadZipFile:
# If not a zipfile, assume it's a KML file
doc = kml_bytes.decode('utf-8')
# Remove XML declaration
doc = re.sub(r'<\?xml.*\?>', '', doc)
k = kml.KML()
k.from_string(doc)
features = []
process_feature(features, k)
return {
'type': 'FeatureCollection',
'features': features
}
def process_feature(features, feature):
# Recursive function to handle folders within folders
if isinstance(feature, (kml.Document, kml.Folder, kml.KML)):
for child in feature.features():
process_feature(features, child)
elif isinstance(feature, kml.Placemark):
geom = shape(feature.geometry)
# Only keep points, lines and polygons
if geom.geom_type in ['Point', 'LineString', 'Polygon']:
features.append({
'type': 'Feature',
'properties': {
'name': feature.name,
'description': feature.description,
},
'geometry': mapping(geom),
})
if feature.extended_data is not None:
features['properties'].update(feature.extended_data)
else:
# raise ValueError(f'Unknown feature: {type(feature)}')
pass

View File

@ -0,0 +1,14 @@
from functools import wraps
from django.http import HttpResponse
def login_required_401(view_func):
@wraps(view_func)
def _wrapped_view(request, *args, **kwargs):
if request.user.is_authenticated:
return view_func(request, *args, **kwargs)
else:
return HttpResponse('Unauthorized', status=401)
return _wrapped_view

View File

@ -1,2 +1,5 @@
django==5.0.6
psycopg2-binary==2.9.9
shapely==2.0.4
fastkml==0.12
lxml==5.2.2

View File

@ -1,9 +1,12 @@
from django.urls import re_path, include, path
from django.urls import re_path, path
from users.views import register, index
from users.views import register, dashboard, check_auth
from users.views.upload_item import upload_item, fetch_import_queue
urlpatterns = [
path('', index),
re_path(r"^account/", include("django.contrib.auth.urls")),
re_path(r"^register/", register, name="register"),
re_path(r"^account/register/", register, name="register"),
re_path(r"^user/dashboard/", dashboard, name="dashboard"),
re_path(r"^api/user/status/", check_auth),
re_path(r'^api/item/import/upload/', upload_item, name='upload_file'),
path('api/item/import/get/<int:id>', fetch_import_queue, name='fetch_import_queue'),
]

View File

@ -0,0 +1,3 @@
from .check_auth import *
from .dashboard import *
from .register import *

View File

@ -0,0 +1,17 @@
from django.http import JsonResponse
def check_auth(request):
if request.user.is_authenticated:
data = {
'authorized': True,
'username': request.user.username,
'id': request.user.id
}
else:
data = {
'authorized': False,
'username': None,
'id': None
}
return JsonResponse(data)

View File

@ -0,0 +1,12 @@
from django.http import JsonResponse
from geo_lib.website.auth import login_required_401
@login_required_401
def dashboard(request):
data = {
"username": request.user.username,
"id": request.user.id
}
return JsonResponse(data)

View File

@ -4,10 +4,6 @@ from django.shortcuts import render
from users.forms import CustomUserCreationForm
def index(request):
return render(request, "index.html")
def register(request):
if request.method == "GET":
return render(

View File

@ -0,0 +1,62 @@
from django import forms
from django.contrib.auth.models import User
from django.db import models
from django.http import HttpResponse, JsonResponse
from geo_lib.spatial.kml import kml_to_geojson
class Document(models.Model):
uploaded_at = models.DateTimeField(auto_now_add=True)
upload = models.FileField()
class DocumentForm(forms.Form):
file = forms.FileField()
class ImportQueue(models.Model):
data = models.JSONField()
user = models.ForeignKey(User, on_delete=models.CASCADE)
class Meta:
db_table = 'import_queue'
def upload_item(request):
if request.method == 'POST':
form = DocumentForm(request.POST, request.FILES)
if form.is_valid():
uploaded_file = request.FILES['file']
file_data = uploaded_file.read()
try:
geojson = kml_to_geojson(file_data)
import_queue, created = ImportQueue.objects.get_or_create(data=geojson, user=request.user)
import_queue.save()
msg = 'upload successful'
if not created:
msg = 'data already exists in the import queue'
return JsonResponse({'success': True, 'msg': msg, 'id': import_queue.id}, status=200)
except Exception as e:
print(e) # TODO: logging
return JsonResponse({'success': False, 'msg': 'failed to parse KML/KMZ', 'id': None}, status=400)
# TODO: put the processed data into the database and then return the ID so the frontend can go to the import page and use the ID to start the import
else:
return JsonResponse({'success': False, 'msg': 'invalid upload structure', 'id': None}, status=400)
else:
return HttpResponse(status=405)
def fetch_import_queue(request, id):
if id is None:
return JsonResponse({'success': False, 'msg': 'ID not provided', 'code': 400}, status=400)
try:
queue = ImportQueue.objects.get(id=id)
if queue.user_id != request.user.id:
return JsonResponse({'success': False, 'msg': 'not authorized to view this item', 'code': 403}, status=400)
return JsonResponse({'success': True, 'data': queue.data}, status=200)
except ImportQueue.DoesNotExist:
return JsonResponse({'success': False, 'msg': 'ID does not exist', 'code': 404}, status=400)

View File

@ -3,11 +3,11 @@
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<meta content="width=device-width, initial-scale=1.0" name="viewport">
<title>GeoServer</title>
</head>
<body>
<div id="app"></div>
<div id="app" class="m-10 min-h-screen"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -9,10 +9,19 @@
"preview": "vite preview"
},
"dependencies": {
"vue": "^3.4.21"
"@types/geojson": "^7946.0.14",
"axios": "^1.7.2",
"dropzone-vue": "^0.1.11",
"geojson": "^0.5.0",
"vue": "^3.4.21",
"vue-router": "^4.3.2",
"vuex": "^4.1.0"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.4",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.4",
"vite": "^5.2.8"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@ -1,47 +1,17 @@
<script setup>
import HelloWorld from './components/HelloWorld.vue'
import TheWelcome from './components/TheWelcome.vue'
</script>
https://router.vuejs.org/
<template>
<header>
<img alt="Vue logo" class="logo" src="./assets/logo.svg" width="125" height="125" />
<div class="wrapper">
<HelloWorld msg="You did it!" />
</div>
</header>
<main>
<TheWelcome />
</main>
<router-view v-slot="{ Component }">
<keep-alive>
<component :is="Component"/>
</keep-alive>
</router-view>
</template>
<style scoped>
header {
line-height: 1.5;
<script>
export default {
name: 'App',
methods: {},
async created() {
},
}
.logo {
display: block;
margin: 0 auto 2rem;
}
@media (min-width: 1024px) {
header {
display: flex;
place-items: center;
padding-right: calc(var(--section-gap) / 2);
}
.logo {
margin: 0 2rem 0 0;
}
header .wrapper {
display: flex;
place-items: flex-start;
flex-wrap: wrap;
}
}
</style>
</script>

View File

@ -1,86 +0,0 @@
/* color palette from <https://github.com/vuejs/theme> */
:root {
--vt-c-white: #ffffff;
--vt-c-white-soft: #f8f8f8;
--vt-c-white-mute: #f2f2f2;
--vt-c-black: #181818;
--vt-c-black-soft: #222222;
--vt-c-black-mute: #282828;
--vt-c-indigo: #2c3e50;
--vt-c-divider-light-1: rgba(60, 60, 60, 0.29);
--vt-c-divider-light-2: rgba(60, 60, 60, 0.12);
--vt-c-divider-dark-1: rgba(84, 84, 84, 0.65);
--vt-c-divider-dark-2: rgba(84, 84, 84, 0.48);
--vt-c-text-light-1: var(--vt-c-indigo);
--vt-c-text-light-2: rgba(60, 60, 60, 0.66);
--vt-c-text-dark-1: var(--vt-c-white);
--vt-c-text-dark-2: rgba(235, 235, 235, 0.64);
}
/* semantic color variables for this project */
:root {
--color-background: var(--vt-c-white);
--color-background-soft: var(--vt-c-white-soft);
--color-background-mute: var(--vt-c-white-mute);
--color-border: var(--vt-c-divider-light-2);
--color-border-hover: var(--vt-c-divider-light-1);
--color-heading: var(--vt-c-text-light-1);
--color-text: var(--vt-c-text-light-1);
--section-gap: 160px;
}
@media (prefers-color-scheme: dark) {
:root {
--color-background: var(--vt-c-black);
--color-background-soft: var(--vt-c-black-soft);
--color-background-mute: var(--vt-c-black-mute);
--color-border: var(--vt-c-divider-dark-2);
--color-border-hover: var(--vt-c-divider-dark-1);
--color-heading: var(--vt-c-text-dark-1);
--color-text: var(--vt-c-text-dark-2);
}
}
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
font-weight: normal;
}
body {
min-height: 100vh;
color: var(--color-text);
background: var(--color-background);
transition:
color 0.5s,
background-color 0.5s;
line-height: 1.6;
font-family:
Inter,
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
Oxygen,
Ubuntu,
Cantarell,
'Fira Sans',
'Droid Sans',
'Helvetica Neue',
sans-serif;
font-size: 15px;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@ -0,0 +1 @@
@import 'root.css';

View File

@ -0,0 +1,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -0,0 +1,38 @@
class UserStatus {
constructor(authorized, username, id) {
this.authorized = authorized;
this.username = username;
this.id = id;
}
}
export async function getUserInfo() {
try {
const response = await fetch('/api/user/status/')
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`)
}
const userStatusData = await response.json()
return new UserStatus(userStatusData.authorized, userStatusData.username, userStatusData.id)
} catch (error) {
console.error(error)
return null
}
}
export function getCookie(name) {
let cookieValue = null;
if (document.cookie && document.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim(); // replaced jQuery.trim() with native JS trim()
// Does this cookie string begin with the name we want?
if (cookie.substring(0, name.length + 1) === (name + '=')) {
cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
break;
}
}
}
return cookieValue;
}

View File

@ -0,0 +1,13 @@
import {UserInfo} from "@/assets/js/store-types.ts";
import {getUserInfo} from "@/assets/js/auth.js";
export const authMixin = {
async created() {
const userStatus = await getUserInfo()
if (!userStatus.authorized) {
window.location = "/account/login/"
}
const userInfo = new UserInfo(userStatus.username, userStatus.id)
this.$store.commit('userInfo', userInfo)
},
}

View File

@ -0,0 +1,13 @@
import {getCookie} from "./auth.js"
export class UserInfo {
private username: String;
private id: BigInteger;
private csrftoken: String;
constructor(username: String, userId: BigInteger) {
this.username = username
this.id = userId
this.csrftoken = getCookie("csrftoken")
}
}

View File

@ -0,0 +1,16 @@
import {createStore} from 'vuex'
import {UserInfo} from './store-types'
export default createStore({
state: {
userInfo: UserInfo
}, mutations: {
userInfo(state, payload) {
state.userInfo = payload
}
}, getters: {
// alertExists: (state) => (message) => {
// return state.siteAlerts.includes(message);
// },
}
})

View File

@ -0,0 +1,3 @@
export function capitalizeFirstLetter(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}

View File

@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 261.76 226.69"><path d="M161.096.001l-30.225 52.351L100.647.001H-.005l130.877 226.688L261.749.001z" fill="#41b883"/><path d="M161.096.001l-30.225 52.351L100.647.001H52.346l78.526 136.01L209.398.001z" fill="#34495e"/></svg>

Before

Width:  |  Height:  |  Size: 276 B

View File

@ -1,35 +0,0 @@
@import './base.css';
#app {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
font-weight: normal;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
padding: 3px;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 1024px) {
body {
display: flex;
place-items: center;
}
#app {
display: grid;
grid-template-columns: 1fr 1fr;
padding: 0 2rem;
}
}

View File

@ -1,11 +0,0 @@
<script setup>
</script>
<template>
</template>
<style scoped>
</style>

View File

@ -1,11 +1,10 @@
<script setup>
</script>
<template>
<p>Home page</p>
</template>
<style scoped>
<script setup>
</script>
<style scoped>
</style>

View File

@ -0,0 +1,30 @@
<template>
<p>username: {{ userInfo.username }}</p>
<p>id: {{ userInfo.id }}</p>
</template>
<script>
import {mapState} from "vuex";
import {authMixin} from "@/assets/js/authMixin.js";
export default {
computed: {
...mapState(["userInfo"]),
},
components: {},
mixins: [authMixin],
data() {
return {}
},
methods: {},
async created() {
},
async mounted() {
},
watch: {},
}
</script>
<style scoped>
</style>

View File

@ -0,0 +1,59 @@
<template>
<div v-if="msg !== ''">
<p>{{ msg }}</p>
</div>
<div>
<li v-for="(item, index) in geoJsonData" :key="`item-${index}`">
<pre>
{{ parseGeoJson(item) }}
</pre>
</li>
</div>
</template>
<script>
import {mapState} from "vuex";
import {authMixin} from "@/assets/js/authMixin.js";
import axios from "axios";
import {capitalizeFirstLetter} from "@/assets/js/string.js";
export default {
computed: {
...mapState(["userInfo"]),
},
components: {},
data() {
return {
msg: "",
geoJsonData: {},
}
},
mixins: [authMixin],
props: ['id'],
methods: {
handleError(responseMsg) {
console.log(responseMsg)
this.msg = capitalizeFirstLetter(responseMsg).trim(".") + "."
},
parseGeoJson(item) {
return item
}
},
created() {
axios.get('/api/item/import/get/' + this.id).then(response => {
if (!response.data.success) {
this.handleError(response.data.msg)
} else {
this.geoJsonData = response.data.data
}
}).catch(error => {
this.handleError(error.message)
});
},
};
</script>
<style scoped>
</style>

View File

@ -0,0 +1,90 @@
<template>
<div class="mb-10">
<p>import data</p>
<p>Only KML/KMZ files supported.</p>
</div>
<div class="relative w-[90%] m-auto">
<div>
<input :disabled="disableUpload" type="file" @change="onFileChange">
<button :disabled="disableUpload" @click="upload">Upload</button>
</div>
</div>
<div v-if="uploadMsg !== ''" class="w-[90%] m-auto mt-10" v-html="uploadMsg">
</div>
</template>
<script>
import {mapState} from "vuex"
import {authMixin} from "@/assets/js/authMixin.js";
import axios from "axios";
import {capitalizeFirstLetter} from "@/assets/js/string.js";
export default {
computed: {
...mapState(["userInfo"]),
},
components: {},
mixins: [authMixin],
data() {
return {
file: null,
disableUpload: false,
uploadMsg: ""
}
},
methods: {
onFileChange(e) {
this.file = e.target.files[0]
const fileType = this.file.name.split('.').pop().toLowerCase()
if (fileType !== 'kmz' && fileType !== 'kml') {
alert('Invalid file type. Only KMZ and KML files are allowed.')
e.target.value = '' // Reset the input value
}
},
upload() {
let formData = new FormData();
formData.append('file', this.file);
axios.post('/api/item/import/upload/', formData, {
headers: {
'Content-Type': 'multipart/form-data',
'X-CSRFToken': this.userInfo.csrftoken
}
}).then(response => {
this.uploadMsg = `<p>${capitalizeFirstLetter(response.data.msg).trim(".")}.</p><p><a href="/#/import/process/${response.data.id}">Continue to Import</a>`
this.disableUpload = true
}).catch(error => {
this.handleError(error)
});
},
handleError(error) {
console.log(error);
}
},
async created() {
},
async mounted() {
},
beforeRouteEnter(to, from, next) {
next(async vm => {
vm.file = null
vm.disableUpload = false
vm.uploadMsg = ""
})
},
watch: {},
}
</script>
<style scoped>
.overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 9999;
}
</style>

View File

@ -0,0 +1 @@
export const APIHOST = "http://127.0.0.1:8000"

View File

@ -1,6 +1,14 @@
import './assets/main.css'
import './assets/css/main.css'
import { createApp } from 'vue'
import {createApp} from 'vue'
import App from './App.vue'
import store from "@/assets/js/store.ts";
import router from "@/router.js";
import DropZone from 'dropzone-vue';
import '@/assets/css/root.css'
createApp(App).mount('#app')
createApp(App)
.use(router)
.use(store)
.use(DropZone)
.mount('#app')

View File

@ -0,0 +1,32 @@
import {createRouter, createWebHashHistory} from 'vue-router'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('./components/Home.vue'),
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('./components/dashboard/Dashboard.vue'),
},
{
path: '/import/upload',
name: 'Import Data',
component: () => import('./components/import/Upload.vue'),
},
{
path: '/import/process/:id',
name: 'Process Data',
component: () => import('./components/import/Process.vue'),
props: true
}
]
const router = createRouter({
history: createWebHashHistory(),
routes
})
export default router

View File

@ -0,0 +1,9 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{vue,js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -14,5 +14,14 @@ export default defineConfig({
build: {
outDir: 'dist',
assetsDir: 'static',
}
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8000',
changeOrigin: true,
secure: false,
},
},
},
})

BIN
tile services.ods Normal file

Binary file not shown.