Skip to content

Commit 0596ec9

Browse files
committed
Handle executionrequest tracking
1 parent 26b657d commit 0596ec9

4 files changed

Lines changed: 243 additions & 9 deletions

File tree

src/qgis_geonode/apiclient/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class BaseGeonodeClient(QtCore.QObject):
3131
keyword_list_received = QtCore.pyqtSignal(list)
3232
search_error_received = QtCore.pyqtSignal(NetworkError)
3333
dataset_uploaded = QtCore.pyqtSignal()
34+
dataset_upload_processing = QtCore.pyqtSignal()
3435
dataset_upload_error_received = QtCore.pyqtSignal(NetworkError)
3536

3637
def __init__(
@@ -183,6 +184,10 @@ def upload_layer(
183184
layer, allow_public_access, timeout=10 * 60 * 1000
184185
) # the GeoNode GUI also uses a 10 minute timeout for uploads
185186
self._upload_task.task_done.connect(self.handle_layer_upload)
187+
# Re-emit the task's transition signal so the GUI can swap the
188+
# "uploading" message for "processing on the server" without having
189+
# to know about the QgsTask itself.
190+
self._upload_task.upload_received.connect(self.dataset_upload_processing)
186191
qgis.core.QgsApplication.taskManager().addTask(self._upload_task)
187192

188193
def handle_layer_upload(self, result: bool):

src/qgis_geonode/apiclient/geonode_api_v2.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,15 @@ def get_ordering_fields(self) -> typing.List[typing.Tuple[str, str]]:
7070
def get_dataset_upload_url(self) -> QtCore.QUrl:
7171
return QtCore.QUrl(f"{self.api_url}/uploads/upload/")
7272

73+
def get_execution_request_url_template(self) -> str:
74+
# NOTE: no trailing slash — the GeoNode router serves the detail at
75+
# ``/executionrequest/{id}`` and a request to ``/{id}/`` returns a 3xx
76+
# redirect that Qt does not follow, leaving the response with
77+
# ``http_status=301/302`` and no error → ``handle_layer_upload`` falls
78+
# through to its generic "Could not upload layer to GeoNode" branch
79+
# even though the upload itself was fine.
80+
return f"{self.api_url}/executionrequest/{{execution_id}}"
81+
7382
def build_search_query(
7483
self, search_filters: models.GeonodeApiSearchFilters
7584
) -> QtCore.QUrlQuery:
@@ -164,6 +173,7 @@ def get_uploader_task(
164173
self.auth_config,
165174
network_task_timeout=timeout,
166175
description="Upload layer to GeoNode",
176+
execution_request_url_template=self.get_execution_request_url_template(),
167177
)
168178

169179
def handle_layer_upload(self, result: bool):

src/qgis_geonode/gui/geonode_map_layer_config_widget.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,13 +410,24 @@ def upload_layer_to_geonode(self) -> None:
410410
self._layer_upload_api_client.dataset_uploaded.connect(
411411
self.handle_layer_uploaded
412412
)
413+
self._layer_upload_api_client.dataset_upload_processing.connect(
414+
self.handle_layer_upload_processing
415+
)
413416
self._layer_upload_api_client.dataset_upload_error_received.connect(
414417
self.handle_layer_upload_error
415418
)
416419
self._layer_upload_api_client.upload_layer(
417420
self.layer, allow_public_access=self.public_access_chb.isChecked()
418421
)
419422

423+
def handle_layer_upload_processing(self):
424+
# POST is done; the server is now ingesting asynchronously. Keep the
425+
# spinner and the controls disabled, just swap the message.
426+
self._show_message(
427+
"Upload received. GeoNode is processing the dataset...",
428+
add_loading_widget=True,
429+
)
430+
420431
def handle_layer_uploaded(self):
421432
self._toggle_upload_controls(enabled=True)
422433
self._show_message("Layer uploaded successfully!")

src/qgis_geonode/tasks/tasks.py

Lines changed: 217 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import json
12
import typing
23
import urllib.parse
34
import shutil
@@ -18,7 +19,9 @@
1819
)
1920
import qgis.core
2021
from ..httpclient import (
22+
ErrorKind,
2123
HttpMethod,
24+
NetworkError,
2225
NetworkResponse,
2326
Request,
2427
RequestToPerform,
@@ -188,16 +191,27 @@ class LayerUploaderTask(qgis.core.QgsTask):
188191
VECTOR_UPLOAD_FORMAT = ExportFormat("ESRI Shapefile", "shp")
189192
RASTER_UPLOAD_FORMAT = ExportFormat("GTiff", "tif")
190193

194+
_POLL_INTERVAL_MS = 3000
195+
_POLL_OVERALL_TIMEOUT_MS = 60 * 60 * 1000
196+
191197
layer: qgis.core.QgsMapLayer
192198
allow_public_access: bool
193199
authcfg: str
194200
network_task_timeout: int
195201
response: typing.Optional[NetworkResponse]
202+
upload_response: typing.Optional[NetworkResponse]
196203
_upload_url: QtCore.QUrl
204+
_execution_request_url_template: typing.Optional[str]
197205
_temporary_directory: typing.Optional[Path]
198206
_request: typing.Optional[Request]
207+
_poll_wait_loop: typing.Optional[QtCore.QEventLoop]
199208

200209
task_done = QtCore.pyqtSignal(bool)
210+
# Fired once the multipart POST has been accepted by the server (HTTP
211+
# 2xx + execution_id parsed) and the task is about to enter the polling
212+
# phase. The GUI uses this to switch the status message from
213+
# "uploading" to "processing on the server".
214+
upload_received = QtCore.pyqtSignal()
201215

202216
def __init__(
203217
self,
@@ -207,6 +221,7 @@ def __init__(
207221
authcfg: str,
208222
network_task_timeout: int,
209223
description: str = "LayerUploaderTask",
224+
execution_request_url_template: typing.Optional[str] = None,
210225
):
211226
"""Task to perform upload of QGIS layers to remote GeoNode servers.
212227
@@ -216,16 +231,27 @@ def __init__(
216231
here — the surrounding QgsTask is genuinely a worker thread doing
217232
non-network work first; this is the case ``wait_for_signal`` was
218233
ostensibly written for).
234+
235+
``execution_request_url_template`` must contain an ``{execution_id}``
236+
placeholder. When provided, the task does not consider the upload done
237+
once the POST returns 201 — that response only carries the
238+
``execution_id`` for the async ingestion job. The task then polls
239+
``GET /executionrequest/{id}`` until the server reports status
240+
``finished`` (success) or ``failed`` (error). When the template is
241+
``None`` the POST result is the final answer (used by tests).
219242
"""
220243
super().__init__(description)
221244
self.layer = layer
222245
self.allow_public_access = allow_public_access
223246
self.authcfg = authcfg
224247
self.network_task_timeout = network_task_timeout
225248
self._upload_url = upload_url
249+
self._execution_request_url_template = execution_request_url_template
226250
self._temporary_directory = None
227251
self._request = None
252+
self._poll_wait_loop = None
228253
self.response = None
254+
self.upload_response = None
229255

230256
def run(self) -> bool:
231257
if self._is_layer_uploadable():
@@ -258,22 +284,35 @@ def run(self) -> bool:
258284
multipart = self._prepare_multipart(source_path, sld_path=sld_path)
259285
boundary = multipart.boundary().data().decode()
260286

261-
loop = QtCore.QEventLoop()
262-
self._request = Request()
263-
self._request.finished.connect(self._on_request_finished)
264-
self._request.finished.connect(loop.quit)
265-
self._request.send(
287+
self._dispatch_request_blocking(
266288
RequestToPerform(
267289
url=self._upload_url,
268290
method=HttpMethod.POST,
269291
payload=multipart,
270292
content_type=f"multipart/form-data; boundary={boundary}",
271-
),
272-
authcfg=self.authcfg or None,
273-
timeout_ms=self.network_task_timeout,
293+
)
274294
)
275-
loop.exec_()
276295

296+
self.upload_response = self.response
297+
if self.isCanceled():
298+
return False
299+
if self.response is None or not self.response.ok:
300+
return False
301+
302+
if self._execution_request_url_template is None:
303+
return True
304+
305+
execution_id = self._parse_execution_id(self.response.body)
306+
if not execution_id:
307+
self.response = self._synthesise_error_response(
308+
self._upload_url,
309+
"Upload accepted but server did not return an execution_id",
310+
body=self.response.body,
311+
)
312+
return False
313+
314+
self.upload_received.emit()
315+
self.response = self._poll_execution_status(execution_id)
277316
return self.response is not None and self.response.ok
278317

279318
def _on_request_finished(self, response: NetworkResponse) -> None:
@@ -283,6 +322,175 @@ def cancel(self) -> None:
283322
super().cancel()
284323
if self._request is not None:
285324
self._request.cancel()
325+
if self._poll_wait_loop is not None:
326+
self._poll_wait_loop.quit()
327+
328+
def _dispatch_request_blocking(self, request: RequestToPerform) -> None:
329+
"""Send ``request`` and block until it completes.
330+
331+
Uses the same nested ``QEventLoop`` pattern as the original POST —
332+
the worker thread is genuinely waiting for I/O here. The result
333+
lands in ``self.response`` via ``_on_request_finished``.
334+
335+
The ``_on_request_finished`` slot is connected with
336+
``Qt.DirectConnection``: this task QObject lives on the main thread
337+
but ``run()`` executes on a worker thread, so the default
338+
``AutoConnection`` would queue the slot on the main thread's event
339+
loop — which the nested ``QEventLoop`` here does not process. The
340+
loop would then exit on ``loop.quit`` (a same-thread direct call)
341+
with ``self.response`` still ``None``. ``DirectConnection`` makes
342+
the assignment happen synchronously in the worker thread; it's a
343+
plain Python attribute set, so the GIL is enough for safety.
344+
"""
345+
loop = QtCore.QEventLoop()
346+
self._request = Request()
347+
self._request.finished.connect(
348+
self._on_request_finished, type=QtCore.Qt.DirectConnection
349+
)
350+
self._request.finished.connect(loop.quit)
351+
self._request.send(
352+
request,
353+
authcfg=self.authcfg or None,
354+
timeout_ms=self.network_task_timeout,
355+
)
356+
loop.exec_()
357+
358+
def _poll_execution_status(self, execution_id: str) -> NetworkResponse:
359+
"""Poll the executionrequest endpoint until the import terminates.
360+
361+
Returns the last poll response. On a logical failure (HTTP 200 but
362+
``status == "failed"``) the response carries a synthesised
363+
``NetworkError`` so callers can rely on ``response.ok`` alone.
364+
"""
365+
poll_url = QtCore.QUrl(
366+
self._execution_request_url_template.format(execution_id=execution_id)
367+
)
368+
deadline_ms = self._POLL_OVERALL_TIMEOUT_MS
369+
elapsed_ms = 0
370+
last_response: typing.Optional[NetworkResponse] = None
371+
372+
while not self.isCanceled() and elapsed_ms < deadline_ms:
373+
self._dispatch_request_blocking(
374+
RequestToPerform(url=poll_url, method=HttpMethod.GET)
375+
)
376+
last_response = self.response
377+
if self.isCanceled():
378+
return last_response or self._synthesise_error_response(
379+
poll_url, "Upload tracking cancelled"
380+
)
381+
if last_response is None or not last_response.ok:
382+
return last_response or self._synthesise_error_response(
383+
poll_url, "Upload tracking failed: empty response"
384+
)
385+
386+
status = self._parse_execution_status(last_response.body)
387+
if status == "finished":
388+
return last_response
389+
if status == "failed":
390+
return dataclasses.replace(
391+
last_response,
392+
error=NetworkError(
393+
kind=ErrorKind.HTTP,
394+
url=poll_url.toString(),
395+
message=self._failure_message(last_response.body),
396+
body=last_response.body,
397+
),
398+
)
399+
400+
self._sleep_between_polls()
401+
elapsed_ms += self._POLL_INTERVAL_MS
402+
403+
if self.isCanceled():
404+
return last_response or self._synthesise_error_response(
405+
poll_url, "Upload tracking cancelled"
406+
)
407+
return self._synthesise_error_response(
408+
poll_url,
409+
f"Upload tracking timed out after {deadline_ms // 1000}s",
410+
body=last_response.body if last_response is not None else None,
411+
)
412+
413+
def _sleep_between_polls(self) -> None:
414+
"""Block the worker thread for ``_POLL_INTERVAL_MS``, cancellable.
415+
416+
``cancel()`` calls ``quit()`` on this loop to interrupt the wait
417+
immediately rather than waiting out the full interval.
418+
"""
419+
self._poll_wait_loop = QtCore.QEventLoop()
420+
QtCore.QTimer.singleShot(self._POLL_INTERVAL_MS, self._poll_wait_loop.quit)
421+
self._poll_wait_loop.exec_()
422+
self._poll_wait_loop = None
423+
424+
@staticmethod
425+
def _parse_execution_id(body: typing.Optional[bytes]) -> typing.Optional[str]:
426+
if not body:
427+
return None
428+
try:
429+
data = json.loads(body)
430+
except (ValueError, TypeError):
431+
return None
432+
if isinstance(data, dict):
433+
value = data.get("execution_id")
434+
if isinstance(value, str) and value:
435+
return value
436+
return None
437+
438+
@staticmethod
439+
def _parse_execution_status(body: typing.Optional[bytes]) -> typing.Optional[str]:
440+
# DynamicREST wraps detail responses under the serializer's ``name``
441+
# ("request" for ExecutionRequestSerializer); fall back to a flat
442+
# shape so older / non-wrapped responses still parse.
443+
if not body:
444+
return None
445+
try:
446+
data = json.loads(body)
447+
except (ValueError, TypeError):
448+
return None
449+
if not isinstance(data, dict):
450+
return None
451+
wrapped = data.get("request")
452+
record = wrapped if isinstance(wrapped, dict) else data
453+
status = record.get("status")
454+
return status if isinstance(status, str) else None
455+
456+
@staticmethod
457+
def _failure_message(body: typing.Optional[bytes]) -> str:
458+
default = "Upload failed on the GeoNode server"
459+
if not body:
460+
return default
461+
try:
462+
data = json.loads(body)
463+
except (ValueError, TypeError):
464+
return default
465+
if not isinstance(data, dict):
466+
return default
467+
record = data.get("request") if isinstance(data.get("request"), dict) else data
468+
log_text = record.get("log") if isinstance(record.get("log"), str) else None
469+
if log_text:
470+
return f"{default}: {log_text}"
471+
return default
472+
473+
@staticmethod
474+
def _synthesise_error_response(
475+
url: typing.Union[QtCore.QUrl, str],
476+
message: str,
477+
body: typing.Optional[bytes] = None,
478+
) -> NetworkResponse:
479+
url_str = url.toString() if isinstance(url, QtCore.QUrl) else url
480+
request = RequestToPerform(
481+
url=url if isinstance(url, QtCore.QUrl) else QtCore.QUrl(url),
482+
method=HttpMethod.GET,
483+
)
484+
return NetworkResponse(
485+
request=request,
486+
body=body or b"",
487+
error=NetworkError(
488+
kind=ErrorKind.TRANSPORT,
489+
url=url_str,
490+
message=message,
491+
body=body,
492+
),
493+
)
286494

287495
def finished(self, result: bool) -> None:
288496
if self._temporary_directory is not None:

0 commit comments

Comments
 (0)