oqtopus 0.2.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- oqtopus/core/module.py +176 -32
- oqtopus/core/module_operation_task.py +234 -0
- oqtopus/core/module_package.py +27 -10
- oqtopus/core/modules_config.py +2 -0
- oqtopus/core/package_prepare_task.py +240 -25
- oqtopus/gui/database_connection_widget.py +12 -5
- oqtopus/gui/database_create_dialog.py +3 -3
- oqtopus/gui/database_duplicate_dialog.py +4 -4
- oqtopus/gui/logs_widget.py +94 -7
- oqtopus/gui/main_dialog.py +118 -31
- oqtopus/gui/module_selection_widget.py +110 -22
- oqtopus/gui/module_widget.py +647 -61
- oqtopus/gui/parameters_groupbox.py +25 -13
- oqtopus/gui/plugin_widget.py +13 -0
- oqtopus/gui/project_widget.py +5 -0
- oqtopus/gui/settings_dialog.py +2 -0
- oqtopus/oqtopus_plugin.py +10 -1
- oqtopus/ui/module_selection_widget.ui +96 -96
- oqtopus/ui/module_widget.ui +72 -58
- oqtopus/ui/settings_dialog.ui +18 -11
- oqtopus/utils/plugin_utils.py +113 -19
- oqtopus/utils/qt_utils.py +54 -0
- {oqtopus-0.2.0.dist-info → oqtopus-1.0.0.dist-info}/METADATA +1 -1
- oqtopus-1.0.0.dist-info/RECORD +47 -0
- {oqtopus-0.2.0.dist-info → oqtopus-1.0.0.dist-info}/WHEEL +1 -1
- tests/test_imports.py +59 -0
- oqtopus-0.2.0.dist-info/RECORD +0 -45
- {oqtopus-0.2.0.dist-info → oqtopus-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {oqtopus-0.2.0.dist-info → oqtopus-1.0.0.dist-info}/top_level.txt +0 -0
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import os
|
|
2
|
+
import re
|
|
2
3
|
import shutil
|
|
3
4
|
import zipfile
|
|
4
5
|
|
|
5
6
|
import requests
|
|
6
7
|
from qgis.PyQt.QtCore import QThread, pyqtSignal
|
|
7
8
|
|
|
9
|
+
from ..core.module_package import ModulePackage
|
|
8
10
|
from ..utils.plugin_utils import PluginUtils, logger
|
|
9
11
|
|
|
10
12
|
|
|
@@ -12,13 +14,36 @@ class PackagePrepareTaskCanceled(Exception):
|
|
|
12
14
|
pass
|
|
13
15
|
|
|
14
16
|
|
|
17
|
+
def sanitize_filename(name: str) -> str:
|
|
18
|
+
"""Sanitize a string to be safe for use as a filename/directory name.
|
|
19
|
+
|
|
20
|
+
Replaces characters that are problematic on Windows or other filesystems.
|
|
21
|
+
For PR names like "#909 some title", extracts just the number to keep paths short.
|
|
22
|
+
"""
|
|
23
|
+
# For PR-style names starting with #, extract just the number
|
|
24
|
+
pr_match = re.match(r"#?(\d+)", name)
|
|
25
|
+
if pr_match:
|
|
26
|
+
return f"PR_{pr_match.group(1)}"
|
|
27
|
+
|
|
28
|
+
# Replace characters that are invalid on Windows: < > : " / \\ | ? * #
|
|
29
|
+
# Also replace spaces to avoid path issues
|
|
30
|
+
sanitized = re.sub(r'[<>:"/\\|?*#\s]+', "_", name)
|
|
31
|
+
# Remove leading/trailing underscores and dots
|
|
32
|
+
sanitized = sanitized.strip("_.")
|
|
33
|
+
# Limit length to avoid Windows MAX_PATH issues (260 char limit)
|
|
34
|
+
# Keep it very short since zip contents add more nested paths
|
|
35
|
+
if len(sanitized) > 40:
|
|
36
|
+
sanitized = sanitized[:40]
|
|
37
|
+
return sanitized
|
|
38
|
+
|
|
39
|
+
|
|
15
40
|
class PackagePrepareTask(QThread):
|
|
16
41
|
"""
|
|
17
42
|
This class is responsible for preparing the package for the oQtopus module management tool.
|
|
18
43
|
It inherits from QThread to run the preparation process in a separate thread.
|
|
19
44
|
"""
|
|
20
45
|
|
|
21
|
-
signalPackagingProgress = pyqtSignal(float)
|
|
46
|
+
signalPackagingProgress = pyqtSignal(float, int) # progress_percent, bytes_downloaded
|
|
22
47
|
|
|
23
48
|
def __init__(self, parent=None):
|
|
24
49
|
super().__init__(parent)
|
|
@@ -31,6 +56,11 @@ class PackagePrepareTask(QThread):
|
|
|
31
56
|
self.__canceled = False
|
|
32
57
|
self.lastError = None
|
|
33
58
|
|
|
59
|
+
# Track download progress across all assets
|
|
60
|
+
self.__download_total_expected = 0
|
|
61
|
+
self.__download_total_received = 0
|
|
62
|
+
self.__last_emitted_percent = None
|
|
63
|
+
|
|
34
64
|
def startFromZip(self, module_package, zip_file: str):
|
|
35
65
|
self.module_package = module_package
|
|
36
66
|
self.from_zip_file = zip_file
|
|
@@ -60,6 +90,11 @@ class PackagePrepareTask(QThread):
|
|
|
60
90
|
self.__destination_directory = self.__prepare_destination_directory()
|
|
61
91
|
logger.info(f"Destination directory: {self.__destination_directory}")
|
|
62
92
|
|
|
93
|
+
# Reset progress tracking
|
|
94
|
+
self.__download_total_expected = 0
|
|
95
|
+
self.__download_total_received = 0
|
|
96
|
+
self.__last_emitted_percent = None
|
|
97
|
+
|
|
63
98
|
self.__prepare_module_assets(self.module_package)
|
|
64
99
|
self.lastError = None
|
|
65
100
|
|
|
@@ -71,24 +106,30 @@ class PackagePrepareTask(QThread):
|
|
|
71
106
|
def __prepare_destination_directory(self):
|
|
72
107
|
"""
|
|
73
108
|
Prepare the destination directory for the module package.
|
|
74
|
-
This method creates a
|
|
109
|
+
This method creates a cache directory for the package downloads.
|
|
75
110
|
"""
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
111
|
+
# Sanitize the package name to avoid filesystem issues (especially on Windows)
|
|
112
|
+
# PR names can contain special characters like # and spaces
|
|
113
|
+
safe_name = sanitize_filename(self.module_package.name)
|
|
114
|
+
cache_dir = os.path.join(
|
|
115
|
+
PluginUtils.plugin_cache_path(),
|
|
116
|
+
"pkgs",
|
|
79
117
|
self.module_package.organisation,
|
|
80
118
|
self.module_package.repository,
|
|
81
|
-
|
|
119
|
+
safe_name,
|
|
82
120
|
)
|
|
83
|
-
os.makedirs(
|
|
121
|
+
os.makedirs(cache_dir, exist_ok=True)
|
|
84
122
|
|
|
85
|
-
return
|
|
123
|
+
return cache_dir
|
|
86
124
|
|
|
87
125
|
def __prepare_module_assets(self, module_package):
|
|
88
126
|
|
|
127
|
+
# Pre-fetch all file sizes to calculate accurate total progress
|
|
128
|
+
self.__prefetch_download_sizes(module_package)
|
|
129
|
+
|
|
89
130
|
# Download the source or use from zip
|
|
90
131
|
zip_file = self.from_zip_file or self.__download_module_asset(
|
|
91
|
-
module_package.download_url, "source.zip"
|
|
132
|
+
module_package.download_url, "source.zip", module_package
|
|
92
133
|
)
|
|
93
134
|
|
|
94
135
|
module_package.source_package_zip = zip_file
|
|
@@ -101,6 +142,7 @@ class PackagePrepareTask(QThread):
|
|
|
101
142
|
zip_file = self.__download_module_asset(
|
|
102
143
|
module_package.asset_project.download_url,
|
|
103
144
|
module_package.asset_project.type.value + ".zip",
|
|
145
|
+
module_package,
|
|
104
146
|
)
|
|
105
147
|
package_dir = self.__extract_zip_file(zip_file)
|
|
106
148
|
module_package.asset_project.package_zip = zip_file
|
|
@@ -111,52 +153,225 @@ class PackagePrepareTask(QThread):
|
|
|
111
153
|
zip_file = self.__download_module_asset(
|
|
112
154
|
module_package.asset_plugin.download_url,
|
|
113
155
|
module_package.asset_plugin.type.value + ".zip",
|
|
156
|
+
module_package,
|
|
114
157
|
)
|
|
115
158
|
package_dir = self.__extract_zip_file(zip_file)
|
|
116
159
|
module_package.asset_plugin.package_zip = zip_file
|
|
117
160
|
module_package.asset_plugin.package_dir = package_dir
|
|
118
161
|
|
|
119
|
-
def
|
|
162
|
+
def __prefetch_download_sizes(self, module_package):
|
|
163
|
+
"""Pre-fetch Content-Length for all files to be downloaded for accurate progress."""
|
|
164
|
+
urls_to_check = []
|
|
120
165
|
|
|
121
|
-
|
|
166
|
+
# Check source if not from zip
|
|
167
|
+
if self.from_zip_file is None:
|
|
168
|
+
# Only check if not already cached
|
|
169
|
+
cache_filename = self.__get_cache_filename("source.zip", module_package)
|
|
170
|
+
zip_file = os.path.join(self.__destination_directory, cache_filename)
|
|
171
|
+
if not self.__is_cached_and_valid(zip_file):
|
|
172
|
+
urls_to_check.append((module_package.download_url, "source.zip"))
|
|
173
|
+
|
|
174
|
+
# Check project asset
|
|
175
|
+
if module_package.asset_project is not None:
|
|
176
|
+
cache_filename = self.__get_cache_filename(
|
|
177
|
+
module_package.asset_project.type.value + ".zip", module_package
|
|
178
|
+
)
|
|
179
|
+
zip_file = os.path.join(self.__destination_directory, cache_filename)
|
|
180
|
+
if not self.__is_cached_and_valid(zip_file):
|
|
181
|
+
urls_to_check.append(
|
|
182
|
+
(
|
|
183
|
+
module_package.asset_project.download_url,
|
|
184
|
+
module_package.asset_project.type.value + ".zip",
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
# Check plugin asset
|
|
189
|
+
if module_package.asset_plugin is not None:
|
|
190
|
+
cache_filename = self.__get_cache_filename(
|
|
191
|
+
module_package.asset_plugin.type.value + ".zip", module_package
|
|
192
|
+
)
|
|
193
|
+
zip_file = os.path.join(self.__destination_directory, cache_filename)
|
|
194
|
+
if not self.__is_cached_and_valid(zip_file):
|
|
195
|
+
urls_to_check.append(
|
|
196
|
+
(
|
|
197
|
+
module_package.asset_plugin.download_url,
|
|
198
|
+
module_package.asset_plugin.type.value + ".zip",
|
|
199
|
+
)
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
# If everything is cached, skip size checking entirely
|
|
203
|
+
if not urls_to_check:
|
|
204
|
+
self.__download_total_expected = 0
|
|
205
|
+
return
|
|
206
|
+
|
|
207
|
+
total_size = 0
|
|
208
|
+
for url, filename in urls_to_check:
|
|
209
|
+
# Get Content-Length via HEAD request
|
|
210
|
+
try:
|
|
211
|
+
response = requests.head(url, allow_redirects=True, timeout=10)
|
|
212
|
+
content_length = response.headers.get("content-length")
|
|
213
|
+
if content_length:
|
|
214
|
+
file_size = int(content_length)
|
|
215
|
+
total_size += file_size
|
|
216
|
+
else:
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"No Content-Length for '{filename}', progress may be inaccurate"
|
|
219
|
+
)
|
|
220
|
+
except Exception as e:
|
|
221
|
+
logger.warning(f"Failed to get size for '{filename}': {e}")
|
|
222
|
+
|
|
223
|
+
self.__download_total_expected = total_size
|
|
224
|
+
if total_size > 0:
|
|
225
|
+
logger.info(f"Total download size: {total_size / (1024 * 1024):.1f} MB")
|
|
226
|
+
|
|
227
|
+
def __is_cached_and_valid(self, zip_file):
|
|
228
|
+
"""Check if a zip file is cached and valid (quick check)."""
|
|
229
|
+
if not os.path.exists(zip_file):
|
|
230
|
+
return False
|
|
231
|
+
# Quick check: file exists and has reasonable size
|
|
232
|
+
try:
|
|
233
|
+
size = os.path.getsize(zip_file)
|
|
234
|
+
if size < 100: # Too small to be valid
|
|
235
|
+
return False
|
|
236
|
+
# Just check if it opens as a valid zip, don't read entire contents
|
|
237
|
+
with zipfile.ZipFile(zip_file, "r") as zip_test:
|
|
238
|
+
# Quick check - just verify we can read the file list
|
|
239
|
+
_ = zip_test.namelist()
|
|
240
|
+
return True
|
|
241
|
+
except (zipfile.BadZipFile, OSError, Exception):
|
|
242
|
+
return False
|
|
243
|
+
|
|
244
|
+
def __get_cache_filename(self, base_filename: str, module_package):
|
|
245
|
+
"""Generate cache filename, including commit SHA for branches/PRs if available."""
|
|
246
|
+
if module_package.type in (ModulePackage.Type.BRANCH, ModulePackage.Type.PULL_REQUEST):
|
|
247
|
+
if module_package.commit_sha:
|
|
248
|
+
# Include commit SHA in filename for cache invalidation
|
|
249
|
+
name, ext = os.path.splitext(base_filename)
|
|
250
|
+
return f"{name}-{module_package.commit_sha[:8]}{ext}"
|
|
251
|
+
else:
|
|
252
|
+
# No commit SHA available, don't cache (use unique name)
|
|
253
|
+
import time
|
|
254
|
+
|
|
255
|
+
name, ext = os.path.splitext(base_filename)
|
|
256
|
+
return f"{name}-{int(time.time())}{ext}"
|
|
257
|
+
return base_filename
|
|
258
|
+
|
|
259
|
+
def __download_module_asset(self, url: str, filename: str, module_package):
|
|
260
|
+
|
|
261
|
+
cache_filename = self.__get_cache_filename(filename, module_package)
|
|
262
|
+
zip_file = os.path.join(self.__destination_directory, cache_filename)
|
|
263
|
+
|
|
264
|
+
# Check if file already exists and is valid
|
|
265
|
+
if os.path.exists(zip_file):
|
|
266
|
+
try:
|
|
267
|
+
# Quick validation - just check if it's a valid zip structure
|
|
268
|
+
file_size = os.path.getsize(zip_file)
|
|
269
|
+
if file_size > 100: # Has reasonable size
|
|
270
|
+
with zipfile.ZipFile(zip_file, "r") as zip_test:
|
|
271
|
+
# Just verify we can read file list, don't test entire contents
|
|
272
|
+
_ = zip_test.namelist()
|
|
273
|
+
logger.info(f"Using cached: {os.path.basename(zip_file)}")
|
|
274
|
+
# Still emit some progress to show we're not stuck
|
|
275
|
+
self.signalPackagingProgress.emit(-1.0, 0)
|
|
276
|
+
return zip_file
|
|
277
|
+
except (zipfile.BadZipFile, OSError, Exception) as e:
|
|
278
|
+
logger.warning(f"Existing file '{zip_file}' is invalid ({e}), will re-download")
|
|
279
|
+
try:
|
|
280
|
+
os.remove(zip_file)
|
|
281
|
+
except OSError:
|
|
282
|
+
pass
|
|
122
283
|
|
|
123
284
|
# Streaming, so we can iterate over the response.
|
|
124
|
-
|
|
285
|
+
timeout = 60
|
|
286
|
+
logger.info(f"Downloading: {os.path.basename(zip_file)}")
|
|
287
|
+
|
|
288
|
+
try:
|
|
289
|
+
response = requests.get(url, allow_redirects=True, stream=True, timeout=timeout)
|
|
290
|
+
except Exception as e:
|
|
291
|
+
logger.error(f"HTTP request failed: {e}")
|
|
292
|
+
raise
|
|
125
293
|
|
|
126
294
|
# Raise an exception in case of http errors
|
|
127
295
|
response.raise_for_status()
|
|
128
296
|
|
|
129
297
|
self.__checkForCanceled()
|
|
130
298
|
|
|
131
|
-
|
|
132
|
-
|
|
299
|
+
# Get total file size from headers
|
|
300
|
+
content_length = response.headers.get("content-length")
|
|
301
|
+
file_size = int(content_length) if content_length else 0
|
|
302
|
+
|
|
303
|
+
if file_size == 0:
|
|
304
|
+
# Emit indeterminate progress
|
|
305
|
+
self.signalPackagingProgress.emit(-1.0, 0)
|
|
306
|
+
|
|
307
|
+
downloaded_size = 0
|
|
133
308
|
with open(zip_file, "wb") as file:
|
|
134
|
-
|
|
135
|
-
for data in response.iter_content(chunk_size=
|
|
309
|
+
chunk_size = 256 * 1024 # 256KB chunks
|
|
310
|
+
for data in response.iter_content(chunk_size=chunk_size, decode_unicode=False):
|
|
136
311
|
file.write(data)
|
|
137
312
|
|
|
138
313
|
self.__checkForCanceled()
|
|
139
314
|
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
315
|
+
chunk_len = len(data)
|
|
316
|
+
downloaded_size += chunk_len
|
|
317
|
+
self.__download_total_received += chunk_len
|
|
318
|
+
|
|
319
|
+
# Emit progress on percentage change
|
|
320
|
+
self.__emit_progress()
|
|
321
|
+
|
|
322
|
+
# Ensure final progress reflects completion
|
|
323
|
+
self.__emit_progress(force=True)
|
|
144
324
|
|
|
145
325
|
return zip_file
|
|
146
326
|
|
|
327
|
+
def __emit_progress(self, force: bool = False):
|
|
328
|
+
"""Emit download progress as percentage (0-100) or -1 for indeterminate."""
|
|
329
|
+
if self.__download_total_expected <= 0:
|
|
330
|
+
# Size unknown, emit indeterminate progress with bytes downloaded
|
|
331
|
+
self.signalPackagingProgress.emit(-1.0, self.__download_total_received)
|
|
332
|
+
return
|
|
333
|
+
|
|
334
|
+
percent = int((self.__download_total_received * 100) / self.__download_total_expected)
|
|
335
|
+
percent = max(0, min(100, percent)) # Clamp to 0-100
|
|
336
|
+
|
|
337
|
+
if force or self.__last_emitted_percent != percent:
|
|
338
|
+
self.__last_emitted_percent = percent
|
|
339
|
+
self.signalPackagingProgress.emit(float(percent), self.__download_total_received)
|
|
340
|
+
|
|
147
341
|
def __extract_zip_file(self, zip_file):
|
|
148
342
|
# Unzip the file to plugin temp dir
|
|
343
|
+
# Don't set indeterminate here - it confuses the progress when downloading multiple files
|
|
344
|
+
|
|
149
345
|
try:
|
|
150
346
|
with zipfile.ZipFile(zip_file, "r") as zip_ref:
|
|
151
|
-
# Find the top-level directory
|
|
347
|
+
# Find the top-level directory in the zip
|
|
152
348
|
zip_dirname = zip_ref.namelist()[0].split("/")[0]
|
|
153
|
-
package_dir = os.path.join(self.__destination_directory, zip_dirname)
|
|
154
|
-
|
|
155
|
-
if os.path.exists(package_dir):
|
|
156
|
-
shutil.rmtree(package_dir)
|
|
157
349
|
|
|
350
|
+
# Use short "src" name to avoid Windows MAX_PATH issues
|
|
351
|
+
package_dir = os.path.join(self.__destination_directory, "src")
|
|
352
|
+
|
|
353
|
+
# Check if already extracted and valid
|
|
354
|
+
if os.path.exists(package_dir) and os.path.isdir(package_dir):
|
|
355
|
+
# Verify it's not empty and has some expected content
|
|
356
|
+
if os.listdir(package_dir):
|
|
357
|
+
logger.info(
|
|
358
|
+
f"Directory '{package_dir}' already extracted - skipping extraction"
|
|
359
|
+
)
|
|
360
|
+
return package_dir
|
|
361
|
+
else:
|
|
362
|
+
logger.warning(f"Directory '{package_dir}' is empty, will re-extract")
|
|
363
|
+
shutil.rmtree(package_dir)
|
|
364
|
+
|
|
365
|
+
logger.info(f"Extracting '{zip_file}'...")
|
|
158
366
|
zip_ref.extractall(self.__destination_directory)
|
|
159
367
|
|
|
368
|
+
# Rename extracted dir to "src" to shorten paths
|
|
369
|
+
extracted_dir = os.path.join(self.__destination_directory, zip_dirname)
|
|
370
|
+
if extracted_dir != package_dir:
|
|
371
|
+
shutil.move(extracted_dir, package_dir)
|
|
372
|
+
|
|
373
|
+
logger.info(f"Extraction complete: '{package_dir}'")
|
|
374
|
+
|
|
160
375
|
except zipfile.BadZipFile:
|
|
161
376
|
raise Exception(self.tr(f"The selected file '{zip_file}' is not a valid zip archive."))
|
|
162
377
|
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import psycopg
|
|
2
|
-
from pgserviceparser import conf_path as pgserviceparser_conf_path
|
|
3
|
-
from pgserviceparser import service_config as pgserviceparser_service_config
|
|
4
|
-
from pgserviceparser import service_names as pgserviceparser_service_names
|
|
5
2
|
from qgis.PyQt.QtCore import pyqtSignal
|
|
6
3
|
from qgis.PyQt.QtGui import QAction
|
|
7
4
|
from qgis.PyQt.QtWidgets import QDialog, QMenu, QWidget
|
|
8
5
|
|
|
6
|
+
from ..libs.pgserviceparser import conf_path as pgserviceparser_conf_path
|
|
7
|
+
from ..libs.pgserviceparser import service_config as pgserviceparser_service_config
|
|
8
|
+
from ..libs.pgserviceparser import service_names as pgserviceparser_service_names
|
|
9
9
|
from ..utils.plugin_utils import PluginUtils, logger
|
|
10
10
|
from ..utils.qt_utils import CriticalMessageBox, QtUtils
|
|
11
11
|
from .database_create_dialog import DatabaseCreateDialog
|
|
@@ -78,8 +78,14 @@ class DatabaseConnectionWidget(QWidget, DIALOG_UI):
|
|
|
78
78
|
self.db_services_comboBox.clear()
|
|
79
79
|
|
|
80
80
|
try:
|
|
81
|
+
self.db_services_comboBox.addItem(self.tr("Please select a service"), None)
|
|
82
|
+
# Disable the placeholder item
|
|
83
|
+
model = self.db_services_comboBox.model()
|
|
84
|
+
item = model.item(0)
|
|
85
|
+
item.setEnabled(False)
|
|
86
|
+
|
|
81
87
|
for service_name in pgserviceparser_service_names():
|
|
82
|
-
self.db_services_comboBox.addItem(service_name)
|
|
88
|
+
self.db_services_comboBox.addItem(service_name, service_name)
|
|
83
89
|
except Exception as exception:
|
|
84
90
|
CriticalMessageBox(
|
|
85
91
|
self.tr("Error"), self.tr("Can't load database services:"), exception, self
|
|
@@ -87,7 +93,8 @@ class DatabaseConnectionWidget(QWidget, DIALOG_UI):
|
|
|
87
93
|
return
|
|
88
94
|
|
|
89
95
|
def __serviceChanged(self, index=None):
|
|
90
|
-
if
|
|
96
|
+
# Check if placeholder is selected (currentData is None)
|
|
97
|
+
if self.db_services_comboBox.currentData() is None:
|
|
91
98
|
self.db_database_label.setText(self.tr("No database"))
|
|
92
99
|
QtUtils.setForegroundColor(self.db_database_label, PluginUtils.COLOR_WARNING)
|
|
93
100
|
QtUtils.setFontItalic(self.db_database_label, True)
|
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
# ---------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
import psycopg
|
|
26
|
-
from pgserviceparser import service_config as pgserviceparser_service_config
|
|
27
|
-
from pgserviceparser import service_names as pgserviceparser_service_names
|
|
28
|
-
from pgserviceparser import write_service as pgserviceparser_write_service
|
|
29
26
|
from qgis.PyQt.QtCore import Qt
|
|
30
27
|
from qgis.PyQt.QtWidgets import QDialog, QMessageBox
|
|
31
28
|
|
|
29
|
+
from ..libs.pgserviceparser import service_config as pgserviceparser_service_config
|
|
30
|
+
from ..libs.pgserviceparser import service_names as pgserviceparser_service_names
|
|
31
|
+
from ..libs.pgserviceparser import write_service as pgserviceparser_write_service
|
|
32
32
|
from ..utils.plugin_utils import PluginUtils, logger
|
|
33
33
|
from ..utils.qt_utils import OverrideCursor
|
|
34
34
|
|
|
@@ -23,12 +23,12 @@
|
|
|
23
23
|
# ---------------------------------------------------------------------
|
|
24
24
|
|
|
25
25
|
import psycopg
|
|
26
|
-
from pgserviceparser import full_config as pgserviceparser_full_config
|
|
27
|
-
from pgserviceparser import service_config as pgserviceparser_service_config
|
|
28
|
-
from pgserviceparser import write_service as pgserviceparser_write_service
|
|
29
26
|
from qgis.PyQt.QtCore import Qt
|
|
30
27
|
from qgis.PyQt.QtWidgets import QDialog, QMessageBox
|
|
31
28
|
|
|
29
|
+
from ..libs.pgserviceparser import full_config as pgserviceparser_full_config
|
|
30
|
+
from ..libs.pgserviceparser import service_config as pgserviceparser_service_config
|
|
31
|
+
from ..libs.pgserviceparser import write_service as pgserviceparser_write_service
|
|
32
32
|
from ..utils.plugin_utils import PluginUtils, logger
|
|
33
33
|
from ..utils.qt_utils import OverrideCursor
|
|
34
34
|
|
|
@@ -90,7 +90,7 @@ class DatabaseDuplicateDialog(QDialog, DIALOG_UI):
|
|
|
90
90
|
with OverrideCursor(Qt.CursorShape.WaitCursor):
|
|
91
91
|
with database_connection.cursor() as cursor:
|
|
92
92
|
cursor.execute(
|
|
93
|
-
f
|
|
93
|
+
f'CREATE DATABASE "{new_database_name}" TEMPLATE "{self.__existing_service_config.get("dbname")}"'
|
|
94
94
|
)
|
|
95
95
|
except psycopg.Error as e:
|
|
96
96
|
errorText = self.tr(f"Error duplicating database:\n{e}.")
|
oqtopus/gui/logs_widget.py
CHANGED
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import logging
|
|
2
|
+
from datetime import datetime
|
|
2
3
|
|
|
3
4
|
from qgis.PyQt.QtCore import QAbstractItemModel, QModelIndex, QSortFilterProxyModel, Qt
|
|
4
|
-
from qgis.PyQt.
|
|
5
|
-
|
|
5
|
+
from qgis.PyQt.QtGui import QKeySequence
|
|
6
|
+
from qgis.PyQt.QtWidgets import (
|
|
7
|
+
QAbstractItemView,
|
|
8
|
+
QApplication,
|
|
9
|
+
QShortcut,
|
|
10
|
+
QStyle,
|
|
11
|
+
QWidget,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Import and register SQL logging level from pum
|
|
15
|
+
from ..libs.pum import SQL
|
|
6
16
|
from ..utils.plugin_utils import LoggingBridge, PluginUtils
|
|
7
17
|
|
|
8
|
-
|
|
18
|
+
logging.addLevelName(SQL, "SQL")
|
|
9
19
|
|
|
10
20
|
|
|
21
|
+
DIALOG_UI = PluginUtils.get_ui_class("logs_widget.ui")
|
|
22
|
+
|
|
11
23
|
COLUMNS = ["Level", "Module", "Message"]
|
|
12
24
|
|
|
13
25
|
|
|
@@ -33,7 +45,7 @@ class LogModel(QAbstractItemModel):
|
|
|
33
45
|
return len(COLUMNS)
|
|
34
46
|
|
|
35
47
|
def data(self, index: QModelIndex, role: Qt.ItemDataRole = None):
|
|
36
|
-
if not index.isValid()
|
|
48
|
+
if not index.isValid():
|
|
37
49
|
return None
|
|
38
50
|
if (
|
|
39
51
|
index.row() < 0
|
|
@@ -42,8 +54,18 @@ class LogModel(QAbstractItemModel):
|
|
|
42
54
|
or index.column() >= len(COLUMNS)
|
|
43
55
|
):
|
|
44
56
|
return None
|
|
57
|
+
|
|
45
58
|
log = self.logs[index.row()]
|
|
46
|
-
|
|
59
|
+
col_name = COLUMNS[index.column()]
|
|
60
|
+
value = log[col_name]
|
|
61
|
+
|
|
62
|
+
if role == Qt.ItemDataRole.DisplayRole:
|
|
63
|
+
return value
|
|
64
|
+
elif role == Qt.ItemDataRole.ToolTipRole:
|
|
65
|
+
# Show full text in tooltip, especially useful for long messages
|
|
66
|
+
if col_name == "Message":
|
|
67
|
+
return value
|
|
68
|
+
return None
|
|
47
69
|
|
|
48
70
|
def index(self, row: int, column: int, parent=None):
|
|
49
71
|
if row < 0 or row >= len(self.logs) or column < 0 or column >= len(COLUMNS):
|
|
@@ -69,7 +91,7 @@ class LogModel(QAbstractItemModel):
|
|
|
69
91
|
|
|
70
92
|
|
|
71
93
|
class LogFilterProxyModel(QSortFilterProxyModel):
|
|
72
|
-
LEVELS = ["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
94
|
+
LEVELS = ["SQL", "DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"]
|
|
73
95
|
|
|
74
96
|
def __init__(self, parent=None):
|
|
75
97
|
super().__init__(parent)
|
|
@@ -123,13 +145,30 @@ class LogsWidget(QWidget, DIALOG_UI):
|
|
|
123
145
|
self.logs_treeView.setModel(self.proxy_model)
|
|
124
146
|
self.logs_treeView.setAlternatingRowColors(True)
|
|
125
147
|
self.logs_treeView.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
|
|
126
|
-
self.logs_treeView.setSelectionMode(QAbstractItemView.SelectionMode.
|
|
148
|
+
self.logs_treeView.setSelectionMode(QAbstractItemView.SelectionMode.ExtendedSelection)
|
|
127
149
|
self.logs_treeView.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
|
|
150
|
+
|
|
151
|
+
# Enable word wrapping for better readability
|
|
152
|
+
self.logs_treeView.setWordWrap(True)
|
|
153
|
+
self.logs_treeView.setTextElideMode(Qt.TextElideMode.ElideNone)
|
|
154
|
+
|
|
155
|
+
# Configure column widths
|
|
156
|
+
header = self.logs_treeView.header()
|
|
157
|
+
header.setStretchLastSection(True) # Message column stretches to fill space
|
|
158
|
+
header.resizeSection(0, 100) # Level column - fixed width
|
|
159
|
+
header.resizeSection(1, 150) # Module column - fixed width
|
|
160
|
+
# Message column will take remaining space due to setStretchLastSection
|
|
161
|
+
|
|
162
|
+
# Enable automatic row height adjustment
|
|
163
|
+
self.logs_treeView.setUniformRowHeights(False)
|
|
164
|
+
header.setDefaultSectionSize(100)
|
|
165
|
+
|
|
128
166
|
self.loggingBridge.loggedLine.connect(self.__logged_line)
|
|
129
167
|
logging.getLogger().addHandler(self.loggingBridge)
|
|
130
168
|
|
|
131
169
|
self.logs_level_comboBox.addItems(
|
|
132
170
|
[
|
|
171
|
+
"SQL",
|
|
133
172
|
"DEBUG",
|
|
134
173
|
"INFO",
|
|
135
174
|
"WARNING",
|
|
@@ -154,13 +193,20 @@ class LogsWidget(QWidget, DIALOG_UI):
|
|
|
154
193
|
self.logs_clear_toolButton.clicked.connect(self.__logsClearClicked)
|
|
155
194
|
self.logs_filter_LineEdit.textChanged.connect(self.proxy_model.setFilterFixedString)
|
|
156
195
|
|
|
196
|
+
# Add copy shortcut (Ctrl+C)
|
|
197
|
+
self.copy_shortcut = QShortcut(QKeySequence.Copy, self.logs_treeView)
|
|
198
|
+
self.copy_shortcut.activated.connect(self.__copySelectedRows)
|
|
199
|
+
|
|
157
200
|
def close(self):
|
|
158
201
|
# uninstall the logging bridge
|
|
159
202
|
logging.getLogger().removeHandler(self.loggingBridge)
|
|
160
203
|
|
|
161
204
|
def __logged_line(self, record, line):
|
|
205
|
+
# Convert timestamp from record.created (epoch time) to readable format
|
|
206
|
+
timestamp = datetime.fromtimestamp(record.created).strftime("%Y-%m-%d %H:%M:%S")
|
|
162
207
|
|
|
163
208
|
log_entry = {
|
|
209
|
+
"Timestamp": timestamp,
|
|
164
210
|
"Level": record.levelname,
|
|
165
211
|
"Module": record.name,
|
|
166
212
|
"Message": record.msg,
|
|
@@ -180,3 +226,44 @@ class LogsWidget(QWidget, DIALOG_UI):
|
|
|
180
226
|
|
|
181
227
|
def __logsClearClicked(self):
|
|
182
228
|
self.logs_model.clear()
|
|
229
|
+
|
|
230
|
+
def __copySelectedRows(self):
|
|
231
|
+
"""Copy selected rows to clipboard in CSV format."""
|
|
232
|
+
selection_model = self.logs_treeView.selectionModel()
|
|
233
|
+
selected_indexes = selection_model.selectedRows()
|
|
234
|
+
|
|
235
|
+
if not selected_indexes:
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
# Sort by row number to maintain order
|
|
239
|
+
selected_indexes.sort(key=lambda idx: idx.row())
|
|
240
|
+
|
|
241
|
+
# Build CSV content with header
|
|
242
|
+
csv_lines = ["Timestamp,Level,Module,Message"]
|
|
243
|
+
|
|
244
|
+
for proxy_index in selected_indexes:
|
|
245
|
+
# Map proxy index to source model index
|
|
246
|
+
source_index = self.proxy_model.mapToSource(proxy_index)
|
|
247
|
+
row = source_index.row()
|
|
248
|
+
log_entry = self.logs_model.logs[row]
|
|
249
|
+
|
|
250
|
+
# Escape fields that might contain commas or quotes
|
|
251
|
+
def escape_csv(value):
|
|
252
|
+
value = str(value)
|
|
253
|
+
if "," in value or '"' in value or "\n" in value:
|
|
254
|
+
return '"' + value.replace('"', '""') + '"'
|
|
255
|
+
return value
|
|
256
|
+
|
|
257
|
+
csv_line = ",".join(
|
|
258
|
+
[
|
|
259
|
+
escape_csv(log_entry["Timestamp"]),
|
|
260
|
+
escape_csv(log_entry["Level"]),
|
|
261
|
+
escape_csv(log_entry["Module"]),
|
|
262
|
+
escape_csv(log_entry["Message"]),
|
|
263
|
+
]
|
|
264
|
+
)
|
|
265
|
+
csv_lines.append(csv_line)
|
|
266
|
+
|
|
267
|
+
# Copy to clipboard
|
|
268
|
+
clipboard = QApplication.clipboard()
|
|
269
|
+
clipboard.setText("\n".join(csv_lines))
|