1+ import json
12import typing
23import urllib .parse
34import shutil
1819)
1920import qgis .core
2021from ..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