diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 77f712b..5a71c9c 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -8,7 +8,7 @@ on:
- master
jobs:
test-on-pr:
- runs-on: windows-latest
+ runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
@@ -18,12 +18,49 @@ jobs:
with:
python-version: '3.12'
+ - name: Start pygeoapi container
+ run: |
+ docker run --rm -d \
+ -p 5000:80 \
+ --rm --name=pygeoapi \
+ geopython/pygeoapi:latest run-with-hot-reload
+
+ - name: Wait for service to be ready
+ run: |
+ # This gives the container a few seconds to initialize
+ timeout 60s bash -c 'until curl -s localhost:5000 > /dev/null; do sleep 2; done'
+ echo "pygeoapi is up and running"
+
+ - name: Enable admin api
+ run: |
+ docker exec pygeoapi sed -i 's/admin: .*/admin: true/' /pygeoapi/local.config.yml
+
- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
pip install -r requirements_dev.txt
+ - name: Install GUI dependencies
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y \
+ libgl1 \
+ libegl1 \
+ libglx-mesa0 \
+ libglib2.0-0t64 \
+ libdbus-1-3 \
+ libxkbcommon-x11-0 \
+ libxcb-icccm4 \
+ libxcb-image0 \
+ libxcb-keysyms1 \
+ libxcb-randr0 \
+ libxcb-render-util0 \
+ libxcb-xinerama0 \
+ libxcb-xinput0 \
+ libxcb-xfixes0 \
+ libxcb-shape0
+
- name: Run unit tests (headless PyQt)
env:
QT_QPA_PLATFORM: offscreen
diff --git a/models/ConfigData.py b/models/ConfigData.py
index ab6ecba..dccb56a 100644
--- a/models/ConfigData.py
+++ b/models/ConfigData.py
@@ -1,5 +1,5 @@
from dataclasses import dataclass, field, fields, is_dataclass
-from datetime import datetime, timezone
+from datetime import datetime
from enum import Enum
from .utils import update_dataclass_from_dict
@@ -9,6 +9,7 @@
MetadataConfig,
ResourceConfigTemplate,
)
+from ..utils.helper_functions import datetime_to_string
from .top_level.utils import InlineList
from .top_level.providers import ProviderTemplate
from .top_level.providers.records import ProviderTypes
@@ -141,14 +142,6 @@ def all_missing_props(self):
return self._all_missing_props
return []
- def datetime_to_string(self, data: datetime):
- # normalize to UTC and format with Z
- if data.tzinfo is None:
- data = data.replace(tzinfo=timezone.utc)
- else:
- data = data.astimezone(timezone.utc)
- return data.strftime("%Y-%m-%dT%H:%M:%SZ")
-
def asdict_enum_safe(self, obj, datetime_to_str=False):
"""Overwriting dataclass 'asdict' fuction to replace Enums with strings."""
if is_dataclass(obj):
@@ -177,7 +170,7 @@ def asdict_enum_safe(self, obj, datetime_to_str=False):
}
else:
if isinstance(obj, datetime) and datetime_to_str:
- return self.datetime_to_string(obj)
+ return datetime_to_string(obj)
else:
return obj
diff --git a/models/top_level/utils.py b/models/top_level/utils.py
index b39e387..36b8087 100644
--- a/models/top_level/utils.py
+++ b/models/top_level/utils.py
@@ -50,17 +50,3 @@ def bbox_from_list(raw_bbox_list: list):
)
return InlineList(list_bbox_val)
-
-
-def to_iso8601(dt: datetime) -> str:
- """
- Convert datetime to UTC ISO 8601 string, for both naive and aware datetimes.
- """
- if dt.tzinfo is None:
- # Treat naive datetime as UTC
- dt = dt.replace(tzinfo=timezone.utc)
- else:
- # Convert to UTC
- dt = dt.astimezone(timezone.utc)
-
- return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
diff --git a/models/utils.py b/models/utils.py
index c7eee06..f92a5ba 100644
--- a/models/utils.py
+++ b/models/utils.py
@@ -4,7 +4,11 @@
from types import UnionType
from typing import Any, get_origin, get_args, Union, get_type_hints
-from .top_level.utils import InlineList, get_enum_value_from_string
+from .top_level.utils import (
+ InlineList,
+ get_enum_value_from_string,
+)
+from ..utils.helper_functions import datetime_from_string
def update_dataclass_from_dict(
@@ -67,12 +71,7 @@ def update_dataclass_from_dict(
if (datetime in args or expected_type is datetime) and isinstance(
new_value, str
):
- try:
- new_value = datetime.strptime(
- new_value, "%Y-%m-%dT%H:%M:%SZ"
- )
- except:
- pass
+ new_value = datetime_from_string(new_value)
# Exception: remap str to Enum
elif isinstance(expected_type, type) and issubclass(
@@ -294,11 +293,10 @@ def _is_instance_of_type(value, expected_type) -> bool:
# Exception: try cast str to datetime manually
if expected_type is datetime:
- try:
- datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
+ if datetime_from_string(value) is not None:
return True
- except:
- pass
+ else:
+ return False
# Fallback for normal types
return isinstance(value, expected_type)
diff --git a/pb_tool.cfg b/pb_tool.cfg
index f6b59e5..ca0f171 100644
--- a/pb_tool.cfg
+++ b/pb_tool.cfg
@@ -54,7 +54,7 @@ python_files: __init__.py pygeoapi_config.py pygeoapi_config_dialog.py
main_dialog: pygeoapi_config_dialog_base.ui
# Other ui files for dialogs you create (these will be compiled)
-compiled_ui_files:
+compiled_ui_files: server_config_dialog.ui
# Resource file(s) that will be compiled
resource_files: resources.qrc
diff --git a/pygeoapi_config.py b/pygeoapi_config.py
index 825d9a9..528cc51 100644
--- a/pygeoapi_config.py
+++ b/pygeoapi_config.py
@@ -30,6 +30,7 @@
# Import the code for the dialog
from .pygeoapi_config_dialog import PygeoapiConfigDialog
+
import os.path
diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py
index bf975bf..c59c42c 100644
--- a/pygeoapi_config_dialog.py
+++ b/pygeoapi_config_dialog.py
@@ -23,14 +23,18 @@
"""
from copy import deepcopy
-from datetime import datetime, timezone
+from datetime import datetime
import os
+from wsgiref import headers
+import requests
import yaml
+from .utils.helper_functions import datetime_to_string
from .utils.data_diff import diff_yaml_dict
from .ui_widgets.utils import get_url_status
+from .server_config_dialog import Ui_serverDialog
from .models.top_level.providers.records import ProviderTypes
from .ui_widgets.providers.NewProviderWindow import NewProviderWindow
@@ -72,6 +76,32 @@
except:
pass
+headers = {"accept": "*/*", "Content-Type": "application/json; charset=utf-8"}
+
+
+class ServerConfigDialog(QDialog, Ui_serverDialog):
+ """
+ Logic for the Server Configuration Dialog.
+ Inherits from QDialog (functionality) and Ui_serverDialog (layout).
+ """
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.setupUi(self) # Builds the UI defined in Designer
+
+ # Optional: Set default values based on current config if needed
+ # self.ServerHostlineEdit.setText("localhost")
+
+ def get_server_url(self):
+ """
+ Retrieve the server configuration data entered by the user.
+ :return: A dictionary with 'host' and 'port' keys.
+ """
+ host = self.ServerHostlineEdit.text()
+ port = self.ServerSpinBox.value()
+ protocol = "http" if self.radioHttp.isChecked() else "https"
+ return f"{protocol}://{host}:{port}/admin/config"
+
# This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer
FORM_CLASS, _ = uic.loadUiType(
@@ -120,8 +150,9 @@ class CustomDumper(yaml.SafeDumper):
),
)
+ # make sure datetime items are not saved as strings with quotes
def represent_datetime_as_timestamp(dumper, data: datetime):
- value = self.config_data.datetime_to_string(data)
+ value = datetime_to_string(data)
# emit as YAML timestamp → plain scalar, no quotes
return dumper.represent_scalar("tag:yaml.org,2002:timestamp", value)
@@ -143,32 +174,131 @@ def on_button_clicked(self, button):
# You can also check the standard button type
if button == self.buttonBox.button(QDialogButtonBox.Save):
+ # proceed only if UI data inputs are valid
if self._set_validate_ui_data()[0]:
- file_path, _ = QFileDialog.getSaveFileName(
- self, "Save File", "", "YAML Files (*.yml);;All Files (*)"
- )
- # before saving, show diff with "Procced" and "Cancel" options
- if file_path and self._diff_original_and_current_data():
- self.save_to_file(file_path)
+ if self.serverRadio.isChecked():
+ # check #1: show diff with "Procced" and "Cancel" options
+ diff_approved, processed_config_data = (
+ self._diff_original_and_current_data()
+ )
+ if not diff_approved:
+ return
+
+ self.server_config(data_to_push=processed_config_data)
+ else:
+ # check #1: show diff with "Procced" and "Cancel" options
+ diff_approved, processed_config_data = (
+ self._diff_original_and_current_data(get_yaml_output=True)
+ )
+ if not diff_approved:
+ return
+
+ file_path, _ = QFileDialog.getSaveFileName(
+ self, "Save File", "", "YAML Files (*.yml);;All Files (*)"
+ )
+ # check #2: valid file path
+ if file_path:
+ self.save_to_file(processed_config_data, file_path)
elif button == self.buttonBox.button(QDialogButtonBox.Open):
- file_name, _ = QFileDialog.getOpenFileName(
- self, "Open File", "", "YAML Files (*.yml);;All Files (*)"
- )
- self.open_file(file_name)
+ if self.serverRadio.isChecked():
+ self.server_config(data_to_push=None)
+ else:
+ file_name, _ = QFileDialog.getOpenFileName(
+ self, "Open File", "", "YAML Files (*.yml);;All Files (*)"
+ )
+ self.open_file(file_name)
elif button == self.buttonBox.button(QDialogButtonBox.Close):
self.reject()
+ return
+
+ def server_config(self, data_to_push: dict | None = None):
+
+ dialog = ServerConfigDialog(self)
+
+ if dialog.exec_():
+ url = dialog.get_server_url()
+ if data_to_push is not None:
+ self.push_to_server(url, data_to_push)
+ else:
+ self.pull_from_server(url)
+
+ def push_to_server(self, url, data_to_push: dict):
+
+ QMessageBox.information(
+ self,
+ "Information",
+ f"Pushing configuration to: {url}",
+ )
+
+ # TODO: support authentication through the QT framework
+ try:
+ # Send the PUT request to Admin API
+ response = requests.put(url, headers=headers, json=data_to_push)
+ response.raise_for_status()
+
+ QgsMessageLog.logMessage(f"Success! Status Code: {response.status_code}")
+
+ QMessageBox.information(
+ self,
+ "Information",
+ f"Success! Status Code: {response.status_code}",
+ )
+
+ except requests.exceptions.RequestException as e:
+ QgsMessageLog.logMessage(f"An error occurred: {e}")
+ QMessageBox.critical(
+ self,
+ "Error",
+ f"An error occurred pushing the configuration to the server: {e}",
+ )
+
+ def pull_from_server(self, url):
+
+ QMessageBox.information(
+ self,
+ "Information",
+ f"Pulling configuration from: {url}",
+ )
+
+ # TODO: support authentication through the QT framework
+ try:
+ # Send the GET request to Admin API
+ response = requests.get(url, headers=headers)
+ response.raise_for_status()
+
+ QgsMessageLog.logMessage(f"Success! Status Code: {response.status_code}")
+
+ QMessageBox.information(
+ self,
+ "Information",
+ f"Success! Status Code: {response.status_code}",
+ )
+
+ QgsMessageLog.logMessage(f"Response: {response.text}")
+
+ data_dict = response.json()
+ self.update_config_data_and_ui(data_dict)
- def save_to_file(self, file_path):
+ except requests.exceptions.RequestException as e:
+ QgsMessageLog.logMessage(f"An error occurred: {e}")
+
+ QMessageBox.critical(
+ self,
+ "Error",
+ f"An error occurred pulling the configuration from the server: {e}",
+ )
+
+ def save_to_file(self, new_config_data: dict, file_path: str):
if file_path:
QApplication.setOverrideCursor(Qt.WaitCursor)
try:
with open(file_path, "w", encoding="utf-8") as file:
yaml.dump(
- self.config_data.asdict_enum_safe(self.config_data),
+ new_config_data,
file,
Dumper=self.dumper,
default_flow_style=False,
@@ -200,48 +330,53 @@ def open_file(self, file_name):
# QApplication.setOverrideCursor(Qt.WaitCursor)
with open(file_name, "r", encoding="utf-8") as file:
file_content = file.read()
+ yaml_original_data_dict = yaml.safe_load(file_content)
- # reset data
- self.config_data = ConfigData()
+ self.update_config_data_and_ui(yaml_original_data_dict)
- # set data and .all_missing_props:
- yaml_original_data = yaml.safe_load(file_content)
- self.yaml_original_data = deepcopy(yaml_original_data)
+ except Exception as e:
+ QMessageBox.warning(self, "Error", f"Cannot open file:\n{str(e)}")
+ # finally:
+ # QApplication.restoreOverrideCursor()
- self.config_data.set_data_from_yaml(yaml_original_data)
+ def update_config_data_and_ui(self, data_dict):
+ """Use the data from local file or local server to reset the ConfigData and UI."""
- # set UI from data
- self.ui_setter.set_ui_from_data()
+ # reset data
+ self.config_data = ConfigData()
- # log messages about missing or mistyped values during deserialization
- # try/except in case of running it from pytests
- try:
- QgsMessageLog.logMessage(
- f"Errors during deserialization: {self.config_data.error_message}"
- )
- QgsMessageLog.logMessage(
- f"Default values used for missing YAML fields: {self.config_data.defaults_message}"
- )
+ # set data and .all_missing_props:
+ self.yaml_original_data = deepcopy(data_dict)
+ self.config_data.set_data_from_yaml(data_dict)
- # summarize all properties missing/overwitten with defaults
- # atm, warning with the full list of properties
- QgsMessageLog.logMessage(
- f"All missing or replaced properties: {self.config_data.all_missing_props}"
- )
+ # set UI from data
+ self.ui_setter.set_ui_from_data()
- if len(self.config_data.all_missing_props) > 0:
- ReadOnlyTextDialog(
- self,
- "Warning",
- f"All missing or replaced properties (check logs for more details): {self.config_data.all_missing_props}",
- ).exec_()
- except:
- pass
+ # log messages about missing or mistyped values during deserialization
+ # try/except in case of running it from pytests
+ try:
+ QgsMessageLog.logMessage(
+ f"Errors during deserialization: {self.config_data.error_message}"
+ )
+ QgsMessageLog.logMessage(
+ f"Default values used for missing YAML fields: {self.config_data.defaults_message}"
+ )
- except Exception as e:
- QMessageBox.warning(self, "Error", f"Cannot open file:\n{str(e)}")
- # finally:
- # QApplication.restoreOverrideCursor()
+ # summarize all properties missing/overwitten with defaults
+ # atm, warning with the full list of properties
+ all_missing_props = self.config_data.all_missing_props
+ QgsMessageLog.logMessage(
+ f"All missing or replaced properties: {all_missing_props}"
+ )
+
+ if len(all_missing_props) > 0:
+ ReadOnlyTextDialog(
+ self,
+ "Warning",
+ f"All missing or replaced properties (check logs for more details): {all_missing_props}",
+ ).exec_()
+ except:
+ pass # QgsMessageLog import error in pytests, ignore
def _set_validate_ui_data(self) -> tuple[bool, list]:
# Set and validate data from UI
@@ -271,33 +406,49 @@ def _set_validate_ui_data(self) -> tuple[bool, list]:
QMessageBox.warning(f"Error deserializing: {e}")
return
- def _diff_original_and_current_data(self) -> tuple[bool, list]:
+ def _diff_original_and_current_data(
+ self, get_yaml_output=False
+ ) -> tuple[bool, dict]:
"""Before saving the file, show the diff and give an option to proceed or cancel."""
+
+ new_config_data = self.config_data.asdict_enum_safe(
+ self.config_data, datetime_to_str=True
+ )
+
+ # if created from skratch, no original data to compare to
if not self.yaml_original_data:
- return True
+ return True, new_config_data
diff_data = diff_yaml_dict(
self.yaml_original_data,
- self.config_data.asdict_enum_safe(self.config_data),
+ new_config_data,
)
+ # if get_yaml_output, preserve datetime objects without string conversion.
+ # This is needed so the yaml dumper is using representer removing quotes from datetime strings
+ if get_yaml_output:
+ new_config_data = self.config_data.asdict_enum_safe(
+ self.config_data, datetime_to_str=False
+ )
+
+ # if no diff detected, directly accept the changes
if (
len(diff_data["added"])
+ len(diff_data["removed"])
+ len(diff_data["changed"])
== 0
):
- return True
+ return True, new_config_data
- # add a window with the choice
+ # if diff detected, show a window with the choice to approve the diff
QgsMessageLog.logMessage(f"{diff_data}")
dialog = ReadOnlyTextDialog(self, "Warning", diff_data, True)
result = dialog.exec_() # returns QDialog.Accepted (1) or QDialog.Rejected (0)
if result == QDialog.Accepted:
- return True
+ return True, new_config_data
else:
- return False
+ return False, None
def open_templates_path_dialog(self):
"""Defining Server.templates.path path, called from .ui file."""
@@ -555,10 +706,20 @@ def preview_resource(self, model_index: QModelIndex = None):
def delete_resource(self):
"""Delete selected resource. Called from .ui."""
# hide detailed collection UI, show preview
- self.config_data.delete_resource(self)
- self.ui_setter.preview_resource()
- self.ui_setter.refresh_resources_list_ui()
- self.current_res_name = ""
+ if self.current_res_name == "":
+ return
+
+ reply = QMessageBox.question(
+ self,
+ "Confirm action",
+ f"Delete resource '{self.current_res_name}'?",
+ QMessageBox.Yes | QMessageBox.No,
+ )
+ if reply == QMessageBox.Yes:
+ self.config_data.delete_resource(self)
+ self.ui_setter.preview_resource()
+ self.ui_setter.refresh_resources_list_ui()
+ self.current_res_name = ""
def new_resource(self):
"""Called from .ui."""
@@ -588,5 +749,8 @@ def load_resource(self):
res_data = self.config_data.resources[self.current_res_name]
# self.ui_setter.setup_resouce_loaded_ui(res_data)
- # set the values to UI widgets
+ # first, set ConfigData from UI (e.g. in case language was changed)
+ self.data_from_ui_setter.set_data_from_ui()
+
+ # set the values to Resource UI widgets
self.ui_setter.set_resource_ui_from_data(res_data)
diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui
index 3cc6bcb..8177d28 100644
--- a/pygeoapi_config_dialog_base.ui
+++ b/pygeoapi_config_dialog_base.ui
@@ -7,7 +7,7 @@
0
0
870
- 490
+ 947
@@ -17,50 +17,41 @@
icon.pngicon.png
-
- -
-
-
- QDialogButtonBox::Close|QDialogButtonBox::Open|QDialogButtonBox::Save
-
-
-
- -
-
-
-
- Liberation Serif
-
-
-
- Brought to you with ❤️ by ByteRoad
-
-
- Qt::MarkdownText
-
-
-
- -
-
-
- 0
-
-
-
- Server
-
-
-
- 50
+
+
-
+
+
-
+
+
+ true
+
+
+
+
+ 0
+ -96
+ 834
+ 1050
+
-
-
-
-
- bind*
-
-
-
-
+
+
-
+
+
+ 0
+
+
+
+ Server
+
+
+
+ 50
+
+
-
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -69,41 +60,38 @@
padding: 10px;
}
-
-
-
-
-
-
-
-
- -
-
-
- host*
-
-
-
- -
-
-
- port*
-
-
-
- -
-
-
-
-
-
-
- -
-
-
-
-
-
-
+
+
+ bind*
+
+
+
-
+
+
+ -
+
+
+ host*
+
+
+
+ -
+
+
+ port*
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -112,85 +100,72 @@
padding: 10px;
}
-
-
-
-
-
-
-
-
-
-
-
- url*
-
-
-
- -
-
-
-
-
-
-
- -
-
-
+
+
+
+
+
-
-
-
- encoding*
-
-
+
+
-
+
+
+ url*
+
+
+
+ -
+
+
+
-
-
-
+
-
-
- utf-8
-
+
+
+ encoding*
+
+
-
-
-
-
-
-
- -
-
-
-
-
-
-
- mimetype*
-
-
+
-
+
+
-
+
+ utf-8
+
+
+
+
+
-
-
+
-
-
- application/json; charset=UTF-8
-
+
+
+ mimetype*
+
+
+
+ -
+
+
-
+
+ application/json; charset=UTF-8
+
+
+
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- map*
-
-
-
+
+
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -199,40 +174,38 @@
padding: 10px;
}
-
-
-
-
-
-
- -
-
-
- attribution*
-
-
-
- -
-
-
- -
-
-
- url*
-
-
-
-
-
-
-
-
- -
-
-
- logging*
-
-
-
+
+
+ map*
+
+
+
-
+
+
+ -
+
+
+ attribution*
+
+
+
+ -
+
+
+ -
+
+
+ url*
+
+
+
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -241,137 +214,124 @@
padding: 10px;
}
-
-
-
-
+
+
+ logging*
+
+
-
-
-
- level*
-
-
+
+
+ level*
+
+
-
-
-
-
-
-
-
+
+ -
+
+
+
-
-
-
-
- logfile
-
-
+
+
+ logfile
+
+
-
-
-
-
-
-
- -
-
-
- 📂
-
-
-
-
-
-
-
-
- 50
- 100
-
-
-
-
-
-
-
- -
-
+
+
-
+
+
+ -
+
+
+
+ 50
+ 100
+
+
- logformat
+ 📂
+
+
+
+ ..
-
+
+
+
+
+ -
+
+
+ logformat
+
+
-
-
-
-
-
-
-
+
+ -
+
+
+
-
-
-
-
- dateformat
-
-
+
+
+ dateformat
+
+
-
-
-
-
-
-
-
+
+ -
+
+
+
-
-
-
-
- rotation
-
-
- false
-
-
+
+
+ false
+
+
+ rotation
+
+
-
-
-
-
-
-
-
- Qt::ScrollBarAlwaysOff
-
-
-
- 1000
- 22
-
-
-
- QAbstractItemView::NoEditTriggers
-
-
- false
-
-
-
-
-
+
+ -
+
+
+ false
+
+
+
+ 1000
+ 22
+
+
+
+ Qt::ScrollBarAlwaysOff
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+
+
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -380,144 +340,126 @@
padding: 10px;
}
-
-
-
-
-
-
-
-
- language
-
-
-
- -
-
-
-
- -
-
-
- languages
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 10
-
-
-
-
-
+
+
+
+
+
+ -
+
+
+ language
+
+
+
+ -
+
+
+ -
+
+
+ languages
+
+
+
+ -
+
-
-
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 10
+
+
+
+
+ -
+
-
-
+
en-US
-
+
-
-
+
en-GB
-
+
-
-
+
fr-CA
-
+
-
-
+
fr-FR
-
+
-
-
+
pt-PT
-
+
-
-
+
-
-
-
+
- Add
+ Add
-
+
-
-
-
- -
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 10
-
-
-
-
-
- -
-
-
-
-
- 1000
- 80
-
-
-
+
-
-
-
- Delete Selected
-
-
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 10
+
+
+
+
+ -
+
+
+
+ 1000
+ 80
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
-
-
-
-
-
-
-
-
-
-
- -
-
-
- limits
-
-
-
+
+
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -526,132 +468,125 @@
padding: 10px;
}
-
-
-
-
-
-
-
-
-
- default
-
-
- true
-
-
-
- -
-
-
- 9999
-
-
-
-
-
- -
-
-
-
-
-
- on exceed
-
-
-
- -
-
-
-
-
-
- -
-
-
-
-
-
- maximum
-
-
- true
-
-
-
- -
-
-
- 9999
-
-
-
-
-
-
- -
-
+
+
+ limits
+
+
+
-
+
+
-
+
+
+ default
+
+
+ true
+
+
+
+ -
+
+
+ 9999
+
+
+
+
+
+ -
+
+
-
+
+
+ on exceed
+
+
+
+ -
+
+
+
+
+ -
+
+
-
+
+
+ maximum
+
+
+ true
+
+
+
+ -
+
+
+ 9999
+
+
+
+
+
+ -
+
+
+ false
+
- max_distance_x
+ max_distance_x
+
+
+ -
+
- false
+ false
-
-
- -
-
+
+
+ -
+
- false
+ false
-
-
-
- -
-
- max_distance_y
+ max_distance_y
+
+
+ -
+
- false
+ false
-
-
- -
-
+
+
+ -
+
- false
+ false
-
-
-
- -
-
- max_distance_units
+ max_distance_units
+
+
+ -
+
- false
-
-
-
- -
-
-
- false
-
-
-
-
-
-
-
-
-
- -
-
-
- templates
-
-
-
+ false
+
+
+
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -660,81 +595,72 @@
padding: 10px;
}
-
-
-
+
+
+ templates
+
+
-
-
-
- path
-
-
+
+
+ path
+
+
-
-
+
-
-
-
-
- 📂
-
-
-
-
-
-
-
-
-
- 50
- 100
-
-
-
+
+
+
+ 50
+ 100
+
+
+
+ 📂
+
+
+
+ ..
+
+
-
-
-
-
- static
-
-
+
+
+ static
+
+
-
-
+
-
-
-
- 📂
-
-
-
-
-
-
-
-
-
- 50
- 100
-
-
-
+
+
+
+ 50
+ 100
+
+
+
+ 📂
+
+
+
+ ..
+
+
-
-
-
-
-
- -
-
-
-
-
-
-
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -743,244 +669,222 @@
padding: 10px;
}
-
-
-
+
+
+
+
+
-
-
-
- admin
-
-
+
+
+ admin
+
+
- -
-
-
-
-
-
-
+ -
+
+
-
+
+
+
-
-
-
-
- gzip
-
-
+
+
+ gzip
+
+
- -
-
-
-
-
-
-
-
+ -
+
+
-
+
+
+
-
-
-
-
- pretty print
-
-
+
+
+ pretty print
+
+
- -
-
-
-
-
-
-
+ -
+
+
-
+
+
+
-
-
-
-
- cors
-
-
+
+
+ cors
+
+
- -
-
-
-
-
-
-
+ -
+
+
-
+
+
+
-
-
-
-
- ogc_schemas_location
-
-
- false
-
-
+
+
+ false
+
+
+ ogc_schemas_location
+
+
-
-
-
-
-
-
- false
-
-
-
-
+
+ -
+
+
+ false
+
+
+
+
-
-
- -
-
-
- icon
-
-
- false
-
-
-
- -
-
-
- false
-
-
-
-
- -
-
-
- logo
-
-
- false
-
-
-
- -
-
-
- false
-
-
-
-
- -
-
-
- locale_dir
-
-
- false
-
-
-
- -
-
-
- false
-
-
-
-
- -
-
-
- api_rules
-
-
- false
-
-
-
- -
-
-
-
-
-
-
- Qt::ScrollBarAlwaysOff
-
-
-
- 1000
- 22
-
-
-
- QAbstractItemView::NoEditTriggers
-
-
- false
-
-
-
-
-
-
- -
-
-
- manager
-
-
- false
-
-
-
- -
-
-
-
-
-
-
- Qt::ScrollBarAlwaysOff
-
-
-
- 1000
- 22
-
-
-
- QAbstractItemView::NoEditTriggers
-
-
- false
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Metadata
-
-
-
-
- 50
-
-
- -
-
-
- identification*
-
-
-
+
-
+
+
+ false
+
+
+ icon
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ false
+
+
+ logo
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ false
+
+
+ locale_dir
+
+
+
+ -
+
+
+ false
+
+
+
+ -
+
+
+ false
+
+
+ api_rules
+
+
+
+ -
+
+
-
+
+
+ false
+
+
+
+ 1000
+ 22
+
+
+
+ Qt::ScrollBarAlwaysOff
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+
+
+
+ -
+
+
+ false
+
+
+ manager
+
+
+
+ -
+
+
-
+
+
+ false
+
+
+
+ 1000
+ 22
+
+
+
+ Qt::ScrollBarAlwaysOff
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+
+
+
+
+
+
+
+
+
+
+ Metadata
+
+
+
+ 50
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -989,365 +893,324 @@
padding: 10px;
}
-
-
-
-
-
-
-
- title*
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
+
+
+ identification*
+
+
+
-
+
+
+ title*
+
+
-
- -
-
+
-
+
-
-
-
-
+
-
-
-
-
-
- en
+
+
+ Qt::Vertical
-
- -
-
- pt
+
+
+ 20
+ 40
+
+
+
+ -
+
+
-
+
+
-
+
+
-
+
+ en
+
+
+ -
+
+ pt
+
+
+ -
+
+ fr
+
+
+
+
+ -
+
+
+ Enter title
+
+
+
+
-
-
- fr
-
+
+
+ Add
+
+
-
+
-
- -
-
-
- Enter title
-
-
-
-
-
-
+
-
-
-
- Add
-
-
+
+
-
+
+
+
+ 1000
+ 200
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
-
+
-
-
-
- -
-
-
-
-
-
-
-
- 1000
- 200
-
-
-
-
- -
-
-
- Delete Selected
-
-
-
-
-
-
-
-
-
-
- -
-
-
- description*
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
+
-
+
+
+ description*
+
+
-
- -
-
+
-
+
-
-
-
-
+
-
-
-
-
-
- en
+
+
+ Qt::Vertical
-
- -
-
- pt
+
+
+ 20
+ 40
+
+
+
+ -
+
+
-
+
+
-
+
+
-
+
+ en
+
+
+ -
+
+ pt
+
+
+ -
+
+ fr
+
+
+
+
+ -
+
+
+ Enter description
+
+
+
+
-
-
- fr
-
+
+
+ Add
+
+
-
+
-
- -
-
-
- Enter description
-
-
-
-
-
-
+
-
-
-
- Add
-
-
+
+
-
+
+
+
+ 1000
+ 200
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
-
+
-
-
-
- -
-
-
-
-
-
-
-
- 1000
- 200
-
-
-
-
- -
-
-
- Delete Selected
-
-
-
-
-
-
-
-
-
- -
-
-
- keywords*
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
+
-
+
+
+ keywords*
+
+
-
- -
-
+
-
+
-
-
-
-
+
-
-
-
-
-
- en
+
+
+ Qt::Vertical
-
- -
-
- pt
+
+
+ 20
+ 40
+
+
+
+ -
+
+
-
+
+
-
+
+
-
+
+ en
+
+
+ -
+
+ pt
+
+
+ -
+
+ fr
+
+
+
+
+ -
+
+
+ Enter keyword
+
+
+
+
-
-
- fr
-
+
+
+ Add
+
+
-
+
-
- -
-
-
- Enter keyword
-
-
-
-
-
-
+
-
-
-
- Add
-
-
+
+
-
+
+
+
+ 1000
+ 200
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
-
+
-
-
-
- -
-
-
-
-
-
-
-
- 1000
- 200
-
-
-
-
- -
-
-
- Delete Selected
-
-
-
-
-
-
-
-
-
- -
-
-
- keywords type
-
-
-
- -
-
-
-
-
- -
-
-
- terms of service
-
-
-
- -
-
-
-
-
- -
-
-
- url*
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- -
-
-
- license*
-
-
-
+
-
+
+
+ keywords type
+
+
+
+ -
+
+
+ -
+
+
+ terms of service
+
+
+
+ -
+
+
+ -
+
+
+ url*
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+ 800
+ 150
+
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1356,52 +1219,44 @@
padding: 10px;
}
-
-
-
- 800
- 150
-
-
-
-
-
-
-
-
- name*
-
-
-
- -
-
-
-
-
- -
-
-
- url
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- -
-
-
- provider*
-
-
-
+
+
+ license*
+
+
+
-
+
+
+ name*
+
+
+
+ -
+
+
+ -
+
+
+ url
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+ 800
+ 150
+
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1410,52 +1265,44 @@
padding: 10px;
}
-
-
-
- 800
- 150
-
-
-
-
-
-
-
-
- name*
-
-
-
- -
-
-
-
-
- -
-
-
- url
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
- -
-
-
- contact*
-
-
-
+
+
+ provider*
+
+
+
-
+
+
+ name*
+
+
+
+ -
+
+
+ -
+
+
+ url
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+ 300
+ 500
+
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1464,205 +1311,165 @@
padding: 10px;
}
-
-
-
- 300
- 500
-
-
-
-
-
-
-
-
- name*
-
-
-
- -
-
-
-
-
- -
-
-
- position
-
-
-
- -
-
-
-
-
- -
-
-
- address
-
-
-
- -
-
-
-
-
- -
-
-
- city
-
-
-
- -
-
-
-
-
- -
-
-
- stateorprovince
-
-
-
- -
-
-
-
-
- -
-
-
- postalcode
-
-
-
- -
-
-
-
-
- -
-
-
- country
-
-
-
- -
-
-
-
-
- -
-
-
- phone
-
-
-
- -
-
-
-
-
- -
-
-
- fax
-
-
-
- -
-
-
-
-
- -
-
-
- email
-
-
-
- -
-
-
-
-
- -
-
-
- url
-
-
-
- -
-
-
-
-
- -
-
-
- hours
-
-
-
- -
-
-
-
-
- -
-
-
- instructions
-
-
-
- -
-
-
-
-
- -
-
-
- role
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- Resources
-
-
- -
-
-
- select collection
-
-
-
+
+
+ contact*
+
+
+
-
+
+
+ name*
+
+
+
+ -
+
+
+ -
+
+
+ position
+
+
+
+ -
+
+
+ -
+
+
+ address
+
+
+
+ -
+
+
+ -
+
+
+ city
+
+
+
+ -
+
+
+ -
+
+
+ stateorprovince
+
+
+
+ -
+
+
+ -
+
+
+ postalcode
+
+
+
+ -
+
+
+ -
+
+
+ country
+
+
+
+ -
+
+
+ -
+
+
+ phone
+
+
+
+ -
+
+
+ -
+
+
+ fax
+
+
+
+ -
+
+
+ -
+
+
+ email
+
+
+
+ -
+
+
+ -
+
+
+ url
+
+
+
+ -
+
+
+ -
+
+
+ hours
+
+
+
+ -
+
+
+ -
+
+
+ instructions
+
+
+
+ -
+
+
+ -
+
+
+ role
+
+
+
+ -
+
+
+
+
+
+
+
+
+
+ Resources
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1671,53 +1478,53 @@
padding: 10px;
}
-
-
-
-
-
-
- Search by name
-
-
-
- -
-
-
- QAbstractItemView::NoEditTriggers
-
-
-
- -
-
-
- Load
-
-
-
- -
-
-
- Delete
-
-
-
- -
-
-
- New
-
-
-
-
-
-
- -
-
-
- collection details
-
-
-
+
+
+ select collection
+
+
+
-
+
+
+ Search by name
+
+
+
+ -
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+
+ -
+
+
+ Load
+
+
+
+ -
+
+
+ Delete
+
+
+
+ -
+
+
+ New
+
+
+
+
+
+
+ -
+
+
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1726,76 +1533,76 @@
padding: 10px;
}
-
-
-
-
-
-
- title
-
-
-
- -
-
-
- true
-
-
-
- -
-
-
- description
-
-
-
- -
-
-
- true
-
-
-
- 0
- 0
-
-
-
- Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
- false
-
-
- collection details
-
-
-
- 50
-
-
-
-
-
-
-
+
+
+ collection details
+
+
+
-
+
+
+ title
+
+
+
+ -
+
+
+ true
+
+
+
+ -
+
+
+ description
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Qt::AlignLeading|Qt::AlignLeft|Qt::AlignTop
+
+
+ true
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+ false
+
+
+ collection details
+
+
+
+ 50
+
+
-
+
+
+
+ 400
+ 150
+
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1805,62 +1612,44 @@
}
-
-
- 400
- 150
-
-
-
-
-
-
-
-
- alias*
-
-
-
- -
-
-
-
-
- -
-
-
- type*
-
-
-
- -
-
-
-
-
- -
-
-
- visibility
-
-
-
- -
-
-
-
+
+ -
+
+
+ alias*
+
+
+
+ -
+
+
+ -
+
+
+ type*
+
+
+
+ -
+
+
+ -
+
+
+ visibility
+
+
+
+ -
+
+
-
-
-
-
- -
-
-
-
- title*
-
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1870,109 +1659,100 @@
}
-
-
-
-
-
-
-
-
-
-
-
-
+
+ title*
+
+
+
-
+
+
-
+
+
-
+
+
Qt::Vertical
-
-
+
+
- 20
- 40
+ 20
+ 40
-
+
-
-
- -
-
-
-
-
-
-
+
+ -
+
+
-
+
+
-
-
-
+
-
- en
+ en
-
- -
+
+ -
- pt
+ pt
-
- -
+
+ -
- fr
+ fr
-
+
-
-
- -
+
+ -
-
+
Enter title
-
-
-
-
-
-
- -
-
-
- Add
-
+
-
+
-
-
-
-
- -
-
-
-
-
-
-
- 1000
- 80
-
-
-
-
- -
-
-
- Delete Selected
-
-
-
+
+ -
+
+
+ Add
+
+
+
-
+
+
+
+ -
+
+
-
+
+
+
+ 1000
+ 80
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
+
-
-
-
-
-
-
-
- -
-
-
-
- description*
-
+
+
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -1982,110 +1762,100 @@
}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ description*
+
+
+
-
+
+
-
+
+
-
+
+
Qt::Vertical
-
-
+
+
- 20
- 40
+ 20
+ 40
-
+
-
-
- -
-
-
-
-
-
-
+
+ -
+
+
-
+
+
-
-
-
+
-
- en
+ en
-
- -
+
+ -
- pt
+ pt
-
- -
+
+ -
- fr
+ fr
-
+
-
-
- -
+
+ -
-
+
Enter description
-
-
-
-
-
-
- -
-
-
- Add
-
+
-
+
-
-
-
-
- -
-
-
-
-
-
-
- 1000
- 80
-
-
-
-
- -
-
-
- Delete Selected
-
-
-
+
+ -
+
+
+ Add
+
+
+
-
+
+
+
+ -
+
+
-
+
+
+
+ 1000
+ 80
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
+
-
-
-
-
-
-
-
- -
-
-
-
- keywords*
-
+
+
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2095,109 +1865,100 @@
}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+ keywords*
+
+
+
-
+
+
-
+
+
-
+
+
Qt::Vertical
-
-
+
+
- 20
- 40
+ 20
+ 40
-
+
-
-
- -
-
-
-
-
-
-
+
+ -
+
+
-
+
+
-
-
-
+
-
- en
+ en
-
- -
+
+ -
- pt
+ pt
-
- -
+
+ -
- fr
+ fr
-
+
-
-
- -
+
+ -
-
+
Enter keyword
-
+
-
-
-
-
- -
-
-
- Add
-
-
-
+
-
-
-
-
- -
-
-
-
-
-
-
- 1000
- 80
-
-
-
-
- -
-
-
- Delete Selected
-
-
-
+
+ -
+
+
+ Add
+
+
+
-
+
+
+
+ -
+
+
-
+
+
+
+ 1000
+ 80
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
+
-
-
+
-
-
-
-
- -
-
-
- links
-
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2207,178 +1968,164 @@
}
-
-
-
-
-
-
-
-
-
-
-
-
+
+ links
+
+
+
-
+
+
-
+
+
-
+
+
Qt::Vertical
-
-
+
+
- 20
- 40
+ 20
+ 40
-
+
-
-
- -
-
-
-
-
-
-
-
-
-
- Type
-
-
-
- -
-
-
- text/csv
-
-
-
-
- -
-
-
- Relations
-
-
-
- -
-
-
- canonical
-
-
-
-
- -
-
-
- URL
-
-
-
- -
-
-
-
-
-
-
-
- -
-
-
- Title
-
-
-
- -
-
-
- (optional)
-
-
-
-
- -
-
-
- Language
-
-
-
- -
-
-
- (optional) e.g. 'en-US'
-
-
-
-
- -
-
-
- Length
-
-
-
- -
-
-
- (optional) content size
-
-
-
-
-
-
-
-
- -
-
-
- Add
-
+
+ -
+
+
-
+
+
-
+
+
+ Type
+
-
-
-
-
-
-
- -
-
-
-
-
-
-
- 200
- 0
-
-
-
-
- 250
- 300
-
-
+
+ -
+
+
+ text/csv
+
-
- -
-
-
- Delete Selected
-
+
+ -
+
+
+ Relations
+
-
+
+ -
+
+
+ canonical
+
+
+
+ -
+
+
+ URL
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+ Title
+
+
+
+ -
+
+
+ (optional)
+
+
+
+ -
+
+
+ Language
+
+
+
+ -
+
+
+ (optional) e.g. 'en-US'
+
+
+
+ -
+
+
+ Length
+
+
+
+ -
+
+
+ (optional) content size
+
+
+
+
+
+ -
+
+
+ Add
+
+
+
-
+
+
+
+ -
+
+
-
+
+
+
+ 200
+ 0
+
+
+
+
+ 250
+ 300
+
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
+
+
-
-
-
-
-
-
- -
-
-
- providers*
-
+
+
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2388,132 +2135,112 @@
}
-
-
-
-
-
-
-
-
-
-
-
- Qt::Vertical
-
-
-
- 20
- 40
-
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
- Add
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
- 200
- 300
-
-
-
-
-
- -
-
-
-
-
-
- Edit Selected
-
-
-
- -
-
-
- Delete Selected
-
-
-
-
-
-
+
+ providers*
+
+
+ -
+
+
-
+
+
-
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 40
+
+
+
+
+ -
+
+
-
+
+
-
+
+
-
-
+
+ -
+
+
+ Add
+
+
+
+
+
-
-
- -
-
-
-
-
-
-
-
- Read-only
-
-
-
-
- -
-
-
-
- 1000
- 40
-
-
-
- QAbstractItemView::NoEditTriggers
-
-
-
-
+
+ -
+
+
-
+
+
+
+ 200
+ 300
+
+
+
+
+ -
+
+
-
+
+
+ Edit Selected
+
+
+
+ -
+
+
+ Delete Selected
+
+
+
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
- spatial extents*
-
+
+
+
+
+
+ -
+
+
-
+
+
+ Read-only
+
+
+
+ -
+
+
+
+ 1000
+ 40
+
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+
+
+
+
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2523,149 +2250,132 @@
}
-
-
-
-
-
-
-
- bbox*
-
-
-
-
- -
-
-
-
-
-
-
-
-
-
- XMin
-
-
-
- -
-
-
- -180
-
-
-
-
-
- -
-
-
-
-
-
- YMin
-
-
-
- -
-
-
- -90
-
-
-
-
-
- -
-
-
-
-
-
- XMax
-
-
-
- -
-
-
- 180
-
-
-
-
-
- -
-
-
-
-
-
- YMax
-
-
-
- -
-
-
- 90
-
-
-
-
-
-
+
+ spatial extents*
+
+
+ -
+
+
+ bbox*
+
+
+
+ -
+
+
-
+
+
-
+
+
+ XMin
+
+
+
+ -
+
+
+ -180
+
+
+
-
-
- -
-
-
- crs
-
-
-
-
- -
-
-
-
-
-
+
+ -
+
+
-
+
+
+ YMin
+
-
- -
-
-
-
-
-
- CRS84
-
-
-
- -
-
-
- Validate
-
-
-
-
-
+
+ -
+
+
+ -90
+
+
+
+
+
+ -
+
+
-
+
+
+ XMax
+
+
+
+ -
+
+
+ 180
+
+
+
+
+
+ -
+
+
-
+
+
+ YMax
+
+
+
+ -
+
+
+ 90
+
+
+
+
+
-
-
-
-
+
+ -
+
+
+ crs
+
+
+
+ -
+
+
-
+
+
+ -
+
+
-
+
+
+ CRS84
+
+
+
+ -
+
+
+ Validate
+
+
+
+
+
+
+
-
-
-
-
-
-
- -
-
-
-
- temporal extents
-
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2675,61 +2385,58 @@
}
-
-
-
-
-
-
- begin
-
-
-
- -
-
-
- 1900-10-30T18:25:00Z
-
-
-
- -
-
-
- end
-
-
-
- -
-
-
- 1900-10-30T18:25:00Z
-
-
-
- -
-
-
- trs
-
-
-
- -
-
-
-
-
-
-
-
-
-
- -
-
-
-
+ temporal extents
+
+
+
-
+
+
+ begin
+
+
+
+ -
+
+
+ 1900-10-30T18:25:00Z
+
+
+
+ -
+
+
+ end
+
+
+
+ -
+
+
+ 1900-10-30T18:25:00Z
+
+
+
+ -
+
+
+ trs
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+ false
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 1); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2739,51 +2446,43 @@
}
-
- false
-
-
-
-
-
-
-
-
- linked-data
-
-
- false
-
-
-
- -
-
-
- Qt::ScrollBarAlwaysOff
-
-
-
- 1000
- 25
-
-
-
- QAbstractItemView::NoEditTriggers
-
-
-
-
-
-
-
-
-
- -
-
-
+
+
+
-
+
+
+ false
+
+
+ linked-data
+
+
+
+ -
+
+
+
+ 1000
+ 25
+
+
+
+ Qt::ScrollBarAlwaysOff
+
+
+ QAbstractItemView::NoEditTriggers
+
+
+
+
+
+
+ -
+
-
+
QGroupBox {
background-color: rgba(240, 240, 240, 0); /* change to your desired color */
border: 0px solid #ffffffff; /* optional: border styling */
@@ -2793,55 +2492,89 @@
}
-
-
-
+
+
+
+
+
-
-
+
Save changes
-
+
-
- -
+
+ -
-
+
Cancel changes
-
+
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+ -
+
+
+ Qt::RightToLeft
+
+
+ Local File
+
+
+ true
+
+
+
+ -
+
+
+ Qt::RightToLeft
+
+
+ Server Connection
+
+
+
+ -
+
+
+ QDialogButtonBox::Close|QDialogButtonBox::Open|QDialogButtonBox::Save
+
+
+
+ -
+
+
+
+ Liberation Serif
+
+
+
+ Brought to you with ❤️ by ByteRoad
+
+
+ Qt::MarkdownText
+
-
-
-
-
-
-
+
+
+
+
+
+
-
- buttonBox
- clicked(QAbstractButton*)
- PygeoapiConfigDialogBase
- on_button_clicked()
-
-
- 271
- 375
-
-
- 271
- 198
-
-
-
pushButtonBrowse
clicked()
@@ -2858,7 +2591,6 @@
-
pushButtonBrowseTemplatesPath
clicked()
@@ -2875,7 +2607,6 @@
-
addServerLangsButton
clicked()
@@ -2892,7 +2623,6 @@
-
deleteServerLangsButton
clicked()
@@ -2909,7 +2639,6 @@
-
addMetadataIdTitleButton
clicked()
@@ -2926,7 +2655,6 @@
-
addMetadataIdDescriptionButton
clicked()
@@ -2943,7 +2671,6 @@
-
addMetadataKeywordButton
clicked()
@@ -2960,8 +2687,6 @@
-
-
addResTitleButton
clicked()
@@ -2978,8 +2703,6 @@
-
-
addResDescriptionButton
clicked()
@@ -2996,8 +2719,6 @@
-
-
addResKeywordsButton
clicked()
@@ -3014,7 +2735,6 @@
-
addResLinksButton
clicked()
@@ -3031,7 +2751,6 @@
-
addResProviderButton
clicked()
@@ -3048,7 +2767,6 @@
-
deleteMetadataIdTitleButton
clicked()
@@ -3065,7 +2783,6 @@
-
deleteMetadataIdDescriptionButton
clicked()
@@ -3082,7 +2799,6 @@
-
deleteResTitleButton
clicked()
@@ -3099,7 +2815,6 @@
-
deleteResDescriptionButton
clicked()
@@ -3116,7 +2831,6 @@
-
deleteResKeywordsButton
clicked()
@@ -3133,7 +2847,6 @@
-
deleteResLinksButton
clicked()
@@ -3150,7 +2863,6 @@
-
editResProviderButton
clicked()
@@ -3167,7 +2879,6 @@
-
deleteResProviderButton
clicked()
@@ -3184,8 +2895,6 @@
-
-
deleteMetadataKeywordButton
clicked()
@@ -3202,7 +2911,6 @@
-
pushButtonBrowseTemplatesStatic
clicked()
@@ -3219,7 +2927,6 @@
-
lineEditCollection
textChanged(QString)
@@ -3300,7 +3007,6 @@
-
pushSaveAndPreviewResource
clicked()
@@ -3333,7 +3039,6 @@
-
validateResExtentsCrsButton
clicked()
@@ -3350,7 +3055,22 @@
-
+
+ buttonBox
+ clicked(QAbstractButton*)
+ PygeoapiConfigDialogBase
+ on_button_clicked()
+
+
+ 271
+ 375
+
+
+ 271
+ 198
+
+
+
open_logfile_dialog()
diff --git a/server_config_dialog.py b/server_config_dialog.py
new file mode 100644
index 0000000..d27ecaa
--- /dev/null
+++ b/server_config_dialog.py
@@ -0,0 +1,88 @@
+# -*- coding: utf-8 -*-
+
+# Form implementation generated from reading ui file 'server_config_dialog.ui'
+#
+# Created by: PyQt5 UI code generator 5.15.11
+#
+# WARNING: Any manual changes made to this file will be lost when pyuic5 is
+# run again. Do not edit this file unless you know what you are doing.
+
+
+from PyQt5 import QtCore, QtGui, QtWidgets
+
+
+class Ui_serverDialog(object):
+ def setupUi(self, serverDialog):
+ serverDialog.setObjectName("serverDialog")
+ serverDialog.resize(382, 173)
+ self.verticalLayout = QtWidgets.QVBoxLayout(serverDialog)
+ self.verticalLayout.setObjectName("verticalLayout")
+ self.serverGroupBox = QtWidgets.QGroupBox(serverDialog)
+ self.serverGroupBox.setTitle("")
+ self.serverGroupBox.setObjectName("serverGroupBox")
+ self.gridLayout = QtWidgets.QGridLayout(self.serverGroupBox)
+ self.gridLayout.setObjectName("gridLayout")
+ self.radioHttp = QtWidgets.QRadioButton(self.serverGroupBox)
+ self.radioHttp.setChecked(True)
+ self.radioHttp.setObjectName("radioHttp")
+ self.gridLayout.addWidget(self.radioHttp, 0, 0, 1, 1)
+ self.radioHttps = QtWidgets.QRadioButton(self.serverGroupBox)
+ self.radioHttps.setObjectName("radioHttps")
+ self.gridLayout.addWidget(self.radioHttps, 0, 1, 1, 1)
+ spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
+ self.gridLayout.addItem(spacerItem, 0, 2, 1, 1)
+ self.verticalLayout.addWidget(self.serverGroupBox)
+ self.serverHorizontalLayout = QtWidgets.QHBoxLayout()
+ self.serverHorizontalLayout.setObjectName("serverHorizontalLayout")
+ self.labelServerHost = QtWidgets.QLabel(serverDialog)
+ self.labelServerHost.setObjectName("labelServerHost")
+ self.serverHorizontalLayout.addWidget(self.labelServerHost)
+ self.ServerHostlineEdit = QtWidgets.QLineEdit(serverDialog)
+ self.ServerHostlineEdit.setObjectName("ServerHostlineEdit")
+ self.serverHorizontalLayout.addWidget(self.ServerHostlineEdit)
+ self.verticalLayout.addLayout(self.serverHorizontalLayout)
+ self.horizontalLayout = QtWidgets.QHBoxLayout()
+ self.horizontalLayout.setObjectName("horizontalLayout")
+ self.serverPortLabel = QtWidgets.QLabel(serverDialog)
+ self.serverPortLabel.setObjectName("serverPortLabel")
+ self.horizontalLayout.addWidget(self.serverPortLabel)
+ self.ServerSpinBox = QtWidgets.QSpinBox(serverDialog)
+ self.ServerSpinBox.setProperty("showGroupSeparator", True)
+ self.ServerSpinBox.setMaximum(100000000)
+ self.ServerSpinBox.setProperty("value", 5000)
+ self.ServerSpinBox.setObjectName("ServerSpinBox")
+ self.horizontalLayout.addWidget(self.ServerSpinBox)
+ spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum)
+ self.horizontalLayout.addItem(spacerItem1)
+ self.verticalLayout.addLayout(self.horizontalLayout)
+ spacerItem2 = QtWidgets.QSpacerItem(20, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding)
+ self.verticalLayout.addItem(spacerItem2)
+ self.serverButtonBox = QtWidgets.QDialogButtonBox(serverDialog)
+ self.serverButtonBox.setOrientation(QtCore.Qt.Horizontal)
+ self.serverButtonBox.setStandardButtons(QtWidgets.QDialogButtonBox.Cancel|QtWidgets.QDialogButtonBox.Ok)
+ self.serverButtonBox.setObjectName("serverButtonBox")
+ self.verticalLayout.addWidget(self.serverButtonBox)
+
+ self.retranslateUi(serverDialog)
+ self.serverButtonBox.accepted.connect(serverDialog.accept) # type: ignore
+ self.serverButtonBox.rejected.connect(serverDialog.reject) # type: ignore
+ QtCore.QMetaObject.connectSlotsByName(serverDialog)
+
+ def retranslateUi(self, serverDialog):
+ _translate = QtCore.QCoreApplication.translate
+ serverDialog.setWindowTitle(_translate("serverDialog", "Dialog"))
+ self.radioHttp.setText(_translate("serverDialog", "http"))
+ self.radioHttps.setText(_translate("serverDialog", "https"))
+ self.labelServerHost.setText(_translate("serverDialog", "Host"))
+ self.ServerHostlineEdit.setText(_translate("serverDialog", "localhost"))
+ self.serverPortLabel.setText(_translate("serverDialog", "Port"))
+
+
+if __name__ == "__main__":
+ import sys
+ app = QtWidgets.QApplication(sys.argv)
+ serverDialog = QtWidgets.QDialog()
+ ui = Ui_serverDialog()
+ ui.setupUi(serverDialog)
+ serverDialog.show()
+ sys.exit(app.exec_())
diff --git a/server_config_dialog.ui b/server_config_dialog.ui
new file mode 100644
index 0000000..e75c511
--- /dev/null
+++ b/server_config_dialog.ui
@@ -0,0 +1,174 @@
+
+
+ serverDialog
+
+
+
+ 0
+ 0
+ 382
+ 173
+
+
+
+ Dialog
+
+
+ -
+
+
+
+
+
+
-
+
+
+ http
+
+
+ true
+
+
+
+ -
+
+
+ https
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+
+ -
+
+
-
+
+
+ Host
+
+
+
+ -
+
+
+ localhost
+
+
+
+
+
+ -
+
+
-
+
+
+ Port
+
+
+
+ -
+
+
+ true
+
+
+ 100000000
+
+
+ 5000
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ 40
+ 20
+
+
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+ QSizePolicy::MinimumExpanding
+
+
+
+ 20
+ 1
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+ QDialogButtonBox::Cancel|QDialogButtonBox::Ok
+
+
+
+
+
+
+
+
+ serverButtonBox
+ accepted()
+ serverDialog
+ accept()
+
+
+ 248
+ 254
+
+
+ 157
+ 274
+
+
+
+
+ serverButtonBox
+ rejected()
+ serverDialog
+ reject()
+
+
+ 316
+ 260
+
+
+ 286
+ 274
+
+
+
+
+
diff --git a/tests/test_server_push.py b/tests/test_server_push.py
new file mode 100644
index 0000000..b22f3bb
--- /dev/null
+++ b/tests/test_server_push.py
@@ -0,0 +1,95 @@
+import pytest
+from copy import deepcopy
+from unittest.mock import MagicMock, patch
+
+from ..utils.data_diff import diff_yaml_dict_remove_known_faulty_fields
+from ..pygeoapi_config_dialog import PygeoapiConfigDialog
+
+# Important: get your pygeoapi instance up & running, first!
+# docker run -p 5000:80 -v $(pwd)/example-config.yml:/pygeoapi/local.config.yml geopython/pygeoapi:latest run-with-hot-reload
+# Double check the SERVER_URL is correct
+
+SERVER_URL = 'http://localhost:5000/admin/config'
+
+@pytest.fixture
+def dialog(qtbot):
+ """Fixture to create the dialog"""
+
+ dialog = PygeoapiConfigDialog()
+ qtbot.addWidget(dialog)
+
+ dialog.update_config_data_and_ui = MagicMock()
+
+ return dialog
+
+@patch("pygeoapi_config.pygeoapi_config_dialog.QgsMessageLog", create=True)
+@patch("pygeoapi_config.pygeoapi_config_dialog.QMessageBox")
+def test_pull_then_push_config(mock_msgbox, mock_log, dialog):
+
+ """Pull config data from server, then push it back."""
+
+ print(f"Pulling data from: {SERVER_URL}", flush=True)
+
+ dialog.pull_from_server(SERVER_URL)
+
+ if mock_msgbox.critical.called:
+ error_call = mock_msgbox.critical.call_args[0][2]
+ pytest.fail(f"Pull operation failed: {error_call}")
+
+ assert dialog.update_config_data_and_ui.called, "update_config_data_and_ui was never called after pull"
+
+ # Get the data that was pulled
+ yaml1_data = dialog.config_data.asdict_enum_safe(
+ deepcopy(dialog.yaml_original_data), datetime_to_str=False
+ )
+
+ pulled_data = dialog.update_config_data_and_ui.call_args[0][0]
+ assert isinstance(pulled_data, dict)
+ print(f"Successfully config data: {list(pulled_data.keys())}", flush=True)
+
+ # Reset mock call history
+ mock_msgbox.information.reset_mock()
+ mock_msgbox.critical.reset_mock()
+
+ print(f"Pushing data back to: {SERVER_URL}", flush=True)
+ dialog.push_to_server(SERVER_URL, pulled_data)
+
+ # Check if push failed
+ if mock_msgbox.critical.called:
+ error_call = mock_msgbox.critical.call_args[0][2]
+ pytest.fail(f"Push operation failed: {error_call}")
+
+ success = False
+ for call in mock_msgbox.information.call_args_list:
+ if "Success! Status Code: 204" in call[0][2]:
+ success = True
+ break
+
+ assert success, "Success message box was not triggered after push"
+ print("Roundtrip Complete: Data pulled and pushed successfully.", flush=True)
+
+ # Pull again and get the data to compare
+ dialog.pull_from_server(SERVER_URL)
+
+ yaml2_data = dialog.config_data.asdict_enum_safe(
+ deepcopy(dialog.yaml_original_data), datetime_to_str=False
+ )
+
+ yaml1_missing_props= None
+
+ diff_data = diff_yaml_dict_remove_known_faulty_fields(
+ yaml1_data, yaml2_data, yaml1_missing_props
+ )
+
+ if (
+ len(diff_data["added"]) + len(diff_data["removed"]) + len(diff_data["changed"])
+ == 0
+ ):
+ assert (True)
+ print(f"No changes detected after the push to: '{SERVER_URL}'.", flush=True)
+ return
+
+ assert (
+ False
+ ), f"YAML data changed after pushing to: '{SERVER_URL}'. \nAdded: {len(diff_data['added'])} fields, changed: {len(diff_data['changed'])} fields, removed: {len(diff_data['removed'])} fields."
+
diff --git a/tests/test_yaml_save.py b/tests/test_yaml_save.py
index c5d6f48..0cae86b 100644
--- a/tests/test_yaml_save.py
+++ b/tests/test_yaml_save.py
@@ -41,8 +41,11 @@ def test_json_schema_on_open_save(qtbot, sample_yaml: str):
dialog.open_file(sample_yaml) # now dialog.config_data has the data stored
# Save YAML
+ processed_config_data = dialog.config_data.asdict_enum_safe(
+ dialog.config_data, datetime_to_str=False
+ )
abs_new_yaml_path = sample_yaml.with_name(f"saved_{sample_yaml.name}")
- dialog.save_to_file(abs_new_yaml_path)
+ dialog.save_to_file(processed_config_data, abs_new_yaml_path)
result = subprocess.run(
[
@@ -98,18 +101,21 @@ def test_open_file_validate_ui_data_save_file(qtbot, sample_yaml: str):
sample_yaml
) # now dialog.config_data has the data stored including .all_missing_props
yaml1_data = dialog.config_data.asdict_enum_safe(
- deepcopy(dialog.yaml_original_data), True
+ deepcopy(dialog.yaml_original_data), datetime_to_str=False
)
yaml1_missing_props = deepcopy(dialog.config_data.all_missing_props)
# Save YAML - EVEN THOUGH some mandatory fields might be missing and recorded as empty strings/lists
+ processed_config_data = dialog.config_data.asdict_enum_safe(
+ dialog.config_data, datetime_to_str=False
+ )
abs_new_yaml_path = sample_yaml.with_name(f"saved_updated_{sample_yaml.name}")
- dialog.save_to_file(abs_new_yaml_path)
+ dialog.save_to_file(processed_config_data, abs_new_yaml_path)
# open the new file
dialog.open_file(abs_new_yaml_path) # now dialog.config_data has the data stored
yaml2_data = dialog.config_data.asdict_enum_safe(
- deepcopy(dialog.yaml_original_data), True
+ deepcopy(dialog.yaml_original_data), datetime_to_str=False
)
# get diff between old and new data
diff --git a/ui_widgets/UiSetter.py b/ui_widgets/UiSetter.py
index ca398d7..697dd2f 100644
--- a/ui_widgets/UiSetter.py
+++ b/ui_widgets/UiSetter.py
@@ -28,6 +28,7 @@
fill_combo_box,
pack_locales_data_into_list,
pack_list_data_into_list_widget,
+ get_default_language,
)
from .utils import get_widget_text_value, reset_widget
@@ -239,22 +240,27 @@ def set_ui_from_data(self):
# incoming type: possible list of strings or dictionary
# limitation: even if YAML had just a list of strings, it will be interpreted here as "en" locale by default
+ default_language = get_default_language(config_data)
+
# title
pack_locales_data_into_list(
config_data.metadata.identification.title,
self.dialog.listWidgetMetadataIdTitle,
+ default_language,
)
# description
pack_locales_data_into_list(
config_data.metadata.identification.description,
self.dialog.listWidgetMetadataIdDescription,
+ default_language,
)
# keywords
pack_locales_data_into_list(
config_data.metadata.identification.keywords,
self.dialog.listWidgetMetadataIdKeywords,
+ default_language,
)
set_combo_box_value_from_data(
combo_box=self.dialog.comboBoxMetadataIdKeywordsType,
@@ -336,6 +342,7 @@ def refresh_resources_list_ui(self):
def set_resource_ui_from_data(self, res_data: ResourceConfigTemplate):
"""Set values for Resource UI from resource data."""
dialog = self.dialog
+ config_data: ConfigData = self.dialog.config_data
# first, reset some fields to defaults (e.g. for data setting, or optional - they might not have a new value to overwrite it)
# data entry fields
@@ -364,20 +371,23 @@ def set_resource_ui_from_data(self, res_data: ResourceConfigTemplate):
value=res_data.type,
)
+ # data with locales
+ default_language = get_default_language(config_data)
+
# title
pack_locales_data_into_list(
- res_data.title,
- dialog.listWidgetResTitle,
+ res_data.title, dialog.listWidgetResTitle, default_language
)
# description
pack_locales_data_into_list(
- res_data.description,
- dialog.listWidgetResDescription,
+ res_data.description, dialog.listWidgetResDescription, default_language
)
# keywords
- pack_locales_data_into_list(res_data.keywords, dialog.listWidgetResKeywords)
+ pack_locales_data_into_list(
+ res_data.keywords, dialog.listWidgetResKeywords, default_language
+ )
# visibility
set_combo_box_value_from_data(
@@ -586,16 +596,6 @@ def setup_map_widget(self):
def preview_resource(self, model_index: "QModelIndex" = None):
dialog = self.dialog
- dialog.current_res_name = model_index.data()
-
- # do nothing, if resource is unsupported
- if isinstance(dialog.config_data.resources[dialog.current_res_name], dict):
- QMessageBox.warning(
- self.dialog,
- "Message",
- f"Preview is not supported for the Resource type '{dialog.config_data.resources[dialog.current_res_name].get('type')}'.",
- )
- return
# if called as a generic preview, no selected collection
if not model_index:
@@ -606,6 +606,19 @@ def preview_resource(self, model_index: "QModelIndex" = None):
dialog.groupBoxCollectionLoaded.hide()
dialog.groupBoxCollectionSelect.show()
dialog.groupBoxCollectionPreview.show()
+
+ dialog.current_res_name = ""
+ return
+
+ dialog.current_res_name = model_index.data()
+
+ # do nothing, if resource is unsupported
+ if isinstance(dialog.config_data.resources[dialog.current_res_name], dict):
+ QMessageBox.warning(
+ self.dialog,
+ "Message",
+ f"Preview is not supported for the Resource type '{dialog.config_data.resources[dialog.current_res_name].get('type')}'.",
+ )
return
# hide detailed collection UI, show preview
diff --git a/ui_widgets/ui_setter_utils.py b/ui_widgets/ui_setter_utils.py
index 3664351..5000cba 100644
--- a/ui_widgets/ui_setter_utils.py
+++ b/ui_widgets/ui_setter_utils.py
@@ -76,17 +76,30 @@ def _apply_red_transparent_style(layer):
layer.triggerRepaint()
-def pack_locales_data_into_list(data, list_widget):
+def get_default_language(config_data) -> str:
+ """Get the default language from ConfigData (server.language or server.languages)."""
+ if config_data.server.language is not None:
+ return config_data.server.language.value.split("-")[0]
+
+ if config_data.server.languages is not None:
+ for lang in config_data.server.languages: # list
+ return lang.split("-")[0]
+
+ return "en"
+
+
+def pack_locales_data_into_list(data, list_widget, default_language="en"):
"""Use ConfigData (list of strings, dict with strings, or a single string) to fill the UI widget list."""
list_widget.clear()
# data can be string, list or dict (for properties like title, description, keywords)
if isinstance(data, str):
if is_valid_string(data):
- value = f"en: {data}"
+ value = f"{default_language}: {data}"
list_widget.addItem(value)
return
+ # 'data' can be a list (iterating through values) or dict (iterating through keys)
for key in data:
if isinstance(data, dict):
local_key_content = data[key]
@@ -101,7 +114,7 @@ def pack_locales_data_into_list(data, list_widget):
list_widget.addItem(value)
elif isinstance(data, list): # list of strings
if is_valid_string(key):
- value = f"en: {key}"
+ value = f"{default_language}: {key}"
list_widget.addItem(value)
diff --git a/utils/data_diff.py b/utils/data_diff.py
index 81f85ad..6ce7371 100644
--- a/utils/data_diff.py
+++ b/utils/data_diff.py
@@ -1,5 +1,8 @@
+from datetime import datetime
from typing import Any
+from .helper_functions import datetime_to_string
+
def diff_yaml_dict(obj1: dict, obj2: dict) -> dict:
"""Returns all added, removed or changed elements between 2 dictionaries."""
@@ -74,6 +77,24 @@ def diff_obj(obj1: Any, obj2: Any, diff: dict, path: str = "") -> dict:
else:
if obj1 != obj2:
+
+ # ignore the case where incoming datetime was never a string
+ if (
+ isinstance(obj1, datetime)
+ and isinstance(obj2, str)
+ and datetime_to_string(obj1) == obj2
+ ):
+ return diff
+
+ # ignore the case where dates came from 'requests' in +00:00 format
+ if (
+ type(obj1) == type(obj2) == str
+ and obj2.endswith("Z")
+ and obj1.endswith("+00:00")
+ and obj2[:-1] == obj1[:-6]
+ ):
+ return diff
+
diff["changed"][path] = {"old": obj1, "new": obj2}
return diff
diff --git a/utils/helper_functions.py b/utils/helper_functions.py
new file mode 100644
index 0000000..a26e21b
--- /dev/null
+++ b/utils/helper_functions.py
@@ -0,0 +1,55 @@
+from datetime import datetime, timezone
+import re
+
+
+def datetime_to_string(data: datetime):
+ # normalize to UTC and format with Z
+ if data.tzinfo is None:
+ data = data.replace(tzinfo=timezone.utc)
+ else:
+ data = data.astimezone(timezone.utc)
+ return data.strftime("%Y-%m-%dT%H:%M:%SZ")
+
+
+def datetime_from_string(value: str) -> datetime | None:
+ """
+ Parse common ISO8601 datetime strings and return a timezone-aware datetime.
+ Accepts:
+ - 2025-12-17T12:34:56Z
+ - 2025-12-17T12:34:56+02:00
+ - 2025-12-17T12:34:56+0200
+ If no timezone is present, returns a UTC-aware datetime (assumption).
+ Returns None if parsing fails.
+ """
+
+ if isinstance(value, datetime):
+ # If timezone-naive, assume UTC
+ if value.tzinfo is None:
+ value = value.replace(tzinfo=timezone.utc)
+ return value
+
+ if not isinstance(value, str):
+ return None
+
+ s = value.strip()
+ # trailing Z -> +00:00
+ if s.endswith("Z"):
+ s = s[:-1] + "+00:00"
+
+ # normalize +0200 -> +02:00
+ s = re.sub(r"([+-]\d{2})(\d{2})$", r"\1:\2", s)
+
+ # Try stdlib (requires offset with colon to return aware dt)
+ try:
+ dt = datetime.fromisoformat(s)
+ except Exception:
+ dt = None
+
+ if dt is None:
+ return None
+
+ # If dt is naive, assume UTC
+ if dt.tzinfo is None:
+ dt = dt.replace(tzinfo=timezone.utc)
+
+ return dt