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