Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Chunked import uploads #1398

Merged
merged 8 commits into from
Sep 18, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 49 additions & 10 deletions app/api/tasks.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import os
import re
import shutil
from wsgiref.util import FileWrapper

import mimetypes

from shutil import copyfileobj
from shutil import copyfileobj, move
from django.core.exceptions import ObjectDoesNotExist, SuspiciousFileOperation, ValidationError
from django.core.files.uploadedfile import InMemoryUploadedFile
from django.db import transaction
Expand All @@ -23,7 +25,7 @@
from .tags import TagsField
from app.security import path_traversal_check
from django.utils.translation import gettext_lazy as _

from webodm import settings

def flatten_files(request_files):
# MultiValueDict in, flat array of files out
Expand Down Expand Up @@ -420,25 +422,62 @@ def post(self, request, project_pk=None):
if import_url and len(files) > 0:
raise exceptions.ValidationError(detail=_("Cannot create task, either specify a URL or upload 1 file."))

chunk_index = request.data.get('dzchunkindex')
uuid = request.data.get('dzuuid')
total_chunk_count = request.data.get('dztotalchunkcount', None)

# Chunked upload?
tmp_upload_file = None
if len(files) > 0 and chunk_index is not None and uuid is not None and total_chunk_count is not None:
byte_offset = request.data.get('dzchunkbyteoffset', 0)

try:
chunk_index = int(chunk_index)
byte_offset = int(byte_offset)
total_chunk_count = int(total_chunk_count)
except ValueError:
raise exceptions.ValidationError(detail="Some parameters are not integers")
uuid = re.sub('[^0-9a-zA-Z-]+', "", uuid)

tmp_upload_file = os.path.join(settings.FILE_UPLOAD_TEMP_DIR, f"{uuid}.upload")
if os.path.isfile(tmp_upload_file) and chunk_index == 0:
os.unlink(tmp_upload_file)

with open(tmp_upload_file, 'ab') as fd:
fd.seek(byte_offset)
if isinstance(files[0], InMemoryUploadedFile):
for chunk in files[0].chunks():
fd.write(chunk)
else:
with open(files[0].temporary_file_path(), 'rb') as file:
fd.write(file.read())

if chunk_index + 1 < total_chunk_count:
return Response({'uploaded': True}, status=status.HTTP_200_OK)

# Ready to import
with transaction.atomic():
task = models.Task.objects.create(project=project,
auto_processing_node=False,
name=task_name,
import_url=import_url if import_url else "file://all.zip",
status=status_codes.RUNNING,
pending_action=pending_actions.IMPORT)
auto_processing_node=False,
name=task_name,
import_url=import_url if import_url else "file://all.zip",
status=status_codes.RUNNING,
pending_action=pending_actions.IMPORT)
task.create_task_directories()
destination_file = task.assets_path("all.zip")

if len(files) > 0:
destination_file = task.assets_path("all.zip")

# Non-chunked file import
if tmp_upload_file is None and len(files) > 0:
with open(destination_file, 'wb+') as fd:
if isinstance(files[0], InMemoryUploadedFile):
for chunk in files[0].chunks():
fd.write(chunk)
else:
with open(files[0].temporary_file_path(), 'rb') as file:
copyfileobj(file, fd)
elif tmp_upload_file is not None:
# Move
shutil.move(tmp_upload_file, destination_file)

worker_tasks.process_task.delay(task.id)

Expand Down
9 changes: 8 additions & 1 deletion app/api/tiler.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from rasterio.enums import ColorInterp
from rasterio.crs import CRS
from rasterio.features import bounds as featureBounds
from rasterio.errors import NotGeoreferencedWarning
import urllib
import os
from .common import get_asset_download_filename
Expand All @@ -16,7 +17,7 @@
from rio_tiler.profiles import img_profiles
from rio_tiler.colormap import cmap as colormap, apply_cmap
from rio_tiler.io import COGReader
from rio_tiler.errors import InvalidColorMapName
from rio_tiler.errors import InvalidColorMapName, AlphaBandWarning
import numpy as np
from .custom_colormaps_helper import custom_colormaps
from app.raster_utils import extension_for_export_format, ZOOM_EXTRA_LEVELS
Expand All @@ -28,7 +29,13 @@
from rest_framework.response import Response
from worker.tasks import export_raster, export_pointcloud
from django.utils.translation import gettext as _
import warnings

# Disable: NotGeoreferencedWarning: Dataset has no geotransform, gcps, or rpcs. The identity matrix be returned.
warnings.filterwarnings("ignore", category=NotGeoreferencedWarning)

# Disable: Alpha band was removed from the output data array
warnings.filterwarnings("ignore", category=AlphaBandWarning)

for custom_colormap in custom_colormaps:
colormap = colormap.register(custom_colormap)
Expand Down
4 changes: 3 additions & 1 deletion app/static/app/js/components/ImportTaskPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,8 @@ class ImportTaskPanel extends React.Component {
clickable: this.uploadButton,
chunkSize: 2147483647,
timeout: 2147483647,

chunking: true,
chunkSize: 16000000, // 16MB
headers: {
[csrf.header]: csrf.token
}
Expand All @@ -69,6 +70,7 @@ class ImportTaskPanel extends React.Component {
this.setState({uploading: false, progress: 0, totalBytes: 0, totalBytesSent: 0});
})
.on("uploadprogress", (file, progress, bytesSent) => {
if (progress == 100) return; // Workaround for chunked upload progress bar jumping around
this.setState({
progress,
totalBytes: file.size,
Expand Down
6 changes: 5 additions & 1 deletion app/static/app/js/components/Map.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -482,7 +482,11 @@ _('Example:'),
});
new AddOverlayCtrl().addTo(this.map);

this.map.fitWorld();
this.map.fitBounds([
[13.772919746115805,
45.664640939831735],
[13.772825784981254,
45.664591558975154]]);
this.map.attributionControl.setPrefix("");

this.setState({showLoading: true});
Expand Down
51 changes: 51 additions & 0 deletions app/tests/test_api_task_import.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,3 +166,54 @@ def test_task(self):
self.assertEqual(corrupted_task.status, status_codes.FAILED)
self.assertTrue("Invalid" in corrupted_task.last_error)

# Test chunked upload import
assets_file = open(assets_path, 'rb')
assets_size = os.path.getsize(assets_path)
chunk_1_size = assets_size // 2
chunk_1_path = os.path.join(os.path.dirname(assets_path), "1.zip")
chunk_2_path = os.path.join(os.path.dirname(assets_path), "2.zip")
with open(chunk_1_path, 'wb') as f:
assets_file.seek(0)
f.write(assets_file.read(chunk_1_size))
with open(chunk_2_path, 'wb') as f:
f.write(assets_file.read())

chunk_1 = open(chunk_1_path, 'rb')
chunk_2 = open(chunk_2_path, 'rb')
assets_file.close()

res = client.post("/api/projects/{}/tasks/import".format(project.id), {
'file': [chunk_1],
'dzuuid': 'abc-test',
'dzchunkindex': 0,
'dztotalchunkcount': 2,
'dzchunkbyteoffset': 0
}, format="multipart")
self.assertEqual(res.status_code, status.HTTP_200_OK)
self.assertTrue(res.data['uploaded'])
chunk_1.close()

res = client.post("/api/projects/{}/tasks/import".format(project.id), {
'file': [chunk_2],
'dzuuid': 'abc-test',
'dzchunkindex': 1,
'dztotalchunkcount': 2,
'dzchunkbyteoffset': chunk_1_size
}, format="multipart")
self.assertEqual(res.status_code, status.HTTP_201_CREATED)
chunk_2.close()

file_import_task = Task.objects.get(id=res.data['id'])
# Wait for completion
c = 0
while c < 10:
worker.tasks.process_pending_tasks()
file_import_task.refresh_from_db()
if file_import_task.status == status_codes.COMPLETED:
break
c += 1
time.sleep(1)

self.assertEqual(file_import_task.import_url, "file://all.zip")
self.assertEqual(file_import_task.images_count, 1)

7 changes: 5 additions & 2 deletions webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,13 @@ let BundleTracker = require('webpack-bundle-tracker');
let ExtractTextPlugin = require('extract-text-webpack-plugin');
let LiveReloadPlugin = require('webpack-livereload-plugin');

const mode = process.argv.indexOf("production") !== -1 ? "production" : "development";
console.log(`Webpack mode: ${mode}`);

module.exports = {
mode: 'development',
mode,
context: __dirname,

entry: {
main: ['./app/static/app/js/main.jsx'],
Console: ['./app/static/app/js/Console.jsx'],
Expand Down