From 79e05e727544d030a60dcd7501c512ca8fe0b328 Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 12:40:57 +0000 Subject: [PATCH 01/24] - updated invalid columnspan to colspan attribute --- pygeoapi_config_dialog_base.ui | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui index 3cc6bcb..b98a434 100644 --- a/pygeoapi_config_dialog_base.ui +++ b/pygeoapi_config_dialog_base.ui @@ -1767,7 +1767,7 @@ - + @@ -1965,7 +1965,7 @@ - + @@ -2078,7 +2078,7 @@ - + From 985c15537a92e266e6d127ac93002f0176bdc86e Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 13:50:55 +0000 Subject: [PATCH 02/24] - added layout and scrollable area to top level widget --- pygeoapi_config_dialog_base.ui | 5000 ++++++++++++++------------------ 1 file changed, 2100 insertions(+), 2900 deletions(-) diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui index b98a434..7d2856d 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 + 0 + 834 + 992 + - - - - 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,565 +2492,66 @@ } - - + + + + + - + Save changes - + - - + + - + Cancel changes - + - - - - - - + + + + + + + + + + + + + + + Liberation Serif + + + + Brought to you with ❤️ by ByteRoad + + + Qt::MarkdownText + + + + + + + QDialogButtonBox::Close|QDialogButtonBox::Open|QDialogButtonBox::Save + - - - - - - + + + + + + - - - buttonBox - clicked(QAbstractButton*) - PygeoapiConfigDialogBase - on_button_clicked() - - - 271 - 375 - - - 271 - 198 - - - - - pushButtonBrowse - clicked() - PygeoapiConfigDialogBase - open_logfile_dialog() - - - 822 - 406 - - - 434 - 244 - - - - - - pushButtonBrowseTemplatesPath - clicked() - PygeoapiConfigDialogBase - open_templates_path_dialog() - - - 822 - 406 - - - 434 - 244 - - - - - - addServerLangsButton - clicked() - PygeoapiConfigDialogBase - add_server_lang() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteServerLangsButton - clicked() - PygeoapiConfigDialogBase - delete_server_lang() - - - 822 - 406 - - - 434 - 244 - - - - - - addMetadataIdTitleButton - clicked() - PygeoapiConfigDialogBase - add_metadata_id_title() - - - 822 - 406 - - - 434 - 244 - - - - - - addMetadataIdDescriptionButton - clicked() - PygeoapiConfigDialogBase - add_metadata_id_description() - - - 822 - 406 - - - 434 - 244 - - - - - - addMetadataKeywordButton - clicked() - PygeoapiConfigDialogBase - add_metadata_keyword() - - - 822 - 406 - - - 434 - 244 - - - - - - - addResTitleButton - clicked() - PygeoapiConfigDialogBase - add_res_title() - - - 822 - 406 - - - 434 - 244 - - - - - - - addResDescriptionButton - clicked() - PygeoapiConfigDialogBase - add_res_description() - - - 822 - 406 - - - 434 - 244 - - - - - - - addResKeywordsButton - clicked() - PygeoapiConfigDialogBase - add_res_keyword() - - - 822 - 406 - - - 434 - 244 - - - - - - addResLinksButton - clicked() - PygeoapiConfigDialogBase - add_res_link() - - - 822 - 406 - - - 434 - 244 - - - - - - addResProviderButton - clicked() - PygeoapiConfigDialogBase - try_add_res_provider() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteMetadataIdTitleButton - clicked() - PygeoapiConfigDialogBase - delete_metadata_id_title() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteMetadataIdDescriptionButton - clicked() - PygeoapiConfigDialogBase - delete_metadata_id_description() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteResTitleButton - clicked() - PygeoapiConfigDialogBase - delete_res_title() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteResDescriptionButton - clicked() - PygeoapiConfigDialogBase - delete_res_description() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteResKeywordsButton - clicked() - PygeoapiConfigDialogBase - delete_res_keyword() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteResLinksButton - clicked() - PygeoapiConfigDialogBase - delete_res_link() - - - 822 - 406 - - - 434 - 244 - - - - - - editResProviderButton - clicked() - PygeoapiConfigDialogBase - edit_res_provider() - - - 822 - 406 - - - 434 - 244 - - - - - - deleteResProviderButton - clicked() - PygeoapiConfigDialogBase - delete_res_provider() - - - 822 - 406 - - - 434 - 244 - - - - - - - deleteMetadataKeywordButton - clicked() - PygeoapiConfigDialogBase - delete_metadata_keyword() - - - 822 - 406 - - - 434 - 244 - - - - - - pushButtonBrowseTemplatesStatic - clicked() - PygeoapiConfigDialogBase - open_templates_static_dialog() - - - 822 - 406 - - - 434 - 244 - - - - - - lineEditCollection - textChanged(QString) - PygeoapiConfigDialogBase - filterResources(QString) - - - 434 - 91 - - - 434 - 244 - - - - - listViewCollection - clicked(QModelIndex) - PygeoapiConfigDialogBase - preview_resource(QModelIndex) - - - 434 - 160 - - - 434 - 244 - - - - - pushDeleteCollection - clicked() - PygeoapiConfigDialogBase - delete_resource() - - - 434 - 160 - - - 434 - 244 - - - - - pushNewCollection - clicked() - PygeoapiConfigDialogBase - new_resource() - - - 434 - 160 - - - 434 - 244 - - - - - pushLoadCollection - clicked() - PygeoapiConfigDialogBase - load_resource() - - - 434 - 160 - - - 434 - 244 - - - - - - pushSaveAndPreviewResource - clicked() - PygeoapiConfigDialogBase - save_resource_edit_and_preview() - - - 434 - 160 - - - 434 - 244 - - - - - pushExitResourceEdit - clicked() - PygeoapiConfigDialogBase - exit_resource_edit() - - - 434 - 160 - - - 434 - 244 - - - - - - validateResExtentsCrsButton - clicked() - PygeoapiConfigDialogBase - validate_res_extents_crs() - - - 434 - 160 - - - 434 - 244 - - - - - + open_logfile_dialog() From a12de62f46bd6f3d3e20d2e7d55d49e128cc3ade Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 14:54:09 +0000 Subject: [PATCH 03/24] - put back connections --- pygeoapi_config_dialog_base.ui | 527 ++++++++++++++++++++++++++++++++- 1 file changed, 526 insertions(+), 1 deletion(-) diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui index 7d2856d..111ea1e 100644 --- a/pygeoapi_config_dialog_base.ui +++ b/pygeoapi_config_dialog_base.ui @@ -2551,7 +2551,532 @@ - + + + buttonBox + clicked(QAbstractButton*) + PygeoapiConfigDialogBase + on_button_clicked() + + + 271 + 375 + + + 271 + 198 + + + + + pushButtonBrowse + clicked() + PygeoapiConfigDialogBase + open_logfile_dialog() + + + 822 + 406 + + + 434 + 244 + + + + + + pushButtonBrowseTemplatesPath + clicked() + PygeoapiConfigDialogBase + open_templates_path_dialog() + + + 822 + 406 + + + 434 + 244 + + + + + + addServerLangsButton + clicked() + PygeoapiConfigDialogBase + add_server_lang() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteServerLangsButton + clicked() + PygeoapiConfigDialogBase + delete_server_lang() + + + 822 + 406 + + + 434 + 244 + + + + + + addMetadataIdTitleButton + clicked() + PygeoapiConfigDialogBase + add_metadata_id_title() + + + 822 + 406 + + + 434 + 244 + + + + + + addMetadataIdDescriptionButton + clicked() + PygeoapiConfigDialogBase + add_metadata_id_description() + + + 822 + 406 + + + 434 + 244 + + + + + + addMetadataKeywordButton + clicked() + PygeoapiConfigDialogBase + add_metadata_keyword() + + + 822 + 406 + + + 434 + 244 + + + + + + + addResTitleButton + clicked() + PygeoapiConfigDialogBase + add_res_title() + + + 822 + 406 + + + 434 + 244 + + + + + + + addResDescriptionButton + clicked() + PygeoapiConfigDialogBase + add_res_description() + + + 822 + 406 + + + 434 + 244 + + + + + + + addResKeywordsButton + clicked() + PygeoapiConfigDialogBase + add_res_keyword() + + + 822 + 406 + + + 434 + 244 + + + + + + addResLinksButton + clicked() + PygeoapiConfigDialogBase + add_res_link() + + + 822 + 406 + + + 434 + 244 + + + + + + addResProviderButton + clicked() + PygeoapiConfigDialogBase + try_add_res_provider() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteMetadataIdTitleButton + clicked() + PygeoapiConfigDialogBase + delete_metadata_id_title() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteMetadataIdDescriptionButton + clicked() + PygeoapiConfigDialogBase + delete_metadata_id_description() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteResTitleButton + clicked() + PygeoapiConfigDialogBase + delete_res_title() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteResDescriptionButton + clicked() + PygeoapiConfigDialogBase + delete_res_description() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteResKeywordsButton + clicked() + PygeoapiConfigDialogBase + delete_res_keyword() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteResLinksButton + clicked() + PygeoapiConfigDialogBase + delete_res_link() + + + 822 + 406 + + + 434 + 244 + + + + + + editResProviderButton + clicked() + PygeoapiConfigDialogBase + edit_res_provider() + + + 822 + 406 + + + 434 + 244 + + + + + + deleteResProviderButton + clicked() + PygeoapiConfigDialogBase + delete_res_provider() + + + 822 + 406 + + + 434 + 244 + + + + + + + deleteMetadataKeywordButton + clicked() + PygeoapiConfigDialogBase + delete_metadata_keyword() + + + 822 + 406 + + + 434 + 244 + + + + + + pushButtonBrowseTemplatesStatic + clicked() + PygeoapiConfigDialogBase + open_templates_static_dialog() + + + 822 + 406 + + + 434 + 244 + + + + + + lineEditCollection + textChanged(QString) + PygeoapiConfigDialogBase + filterResources(QString) + + + 434 + 91 + + + 434 + 244 + + + + + listViewCollection + clicked(QModelIndex) + PygeoapiConfigDialogBase + preview_resource(QModelIndex) + + + 434 + 160 + + + 434 + 244 + + + + + pushDeleteCollection + clicked() + PygeoapiConfigDialogBase + delete_resource() + + + 434 + 160 + + + 434 + 244 + + + + + pushNewCollection + clicked() + PygeoapiConfigDialogBase + new_resource() + + + 434 + 160 + + + 434 + 244 + + + + + pushLoadCollection + clicked() + PygeoapiConfigDialogBase + load_resource() + + + 434 + 160 + + + 434 + 244 + + + + + + pushSaveAndPreviewResource + clicked() + PygeoapiConfigDialogBase + save_resource_edit_and_preview() + + + 434 + 160 + + + 434 + 244 + + + + + pushExitResourceEdit + clicked() + PygeoapiConfigDialogBase + exit_resource_edit() + + + 434 + 160 + + + 434 + 244 + + + + + + validateResExtentsCrsButton + clicked() + PygeoapiConfigDialogBase + validate_res_extents_crs() + + + 434 + 160 + + + 434 + 244 + + + + open_logfile_dialog() From a69ed6f46a539da1656d322d332a8d86d544eca6 Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 15:38:49 +0000 Subject: [PATCH 04/24] - introduced server radio --- pygeoapi_config_dialog.py | 37 ++++++++++---- pygeoapi_config_dialog_base.ui | 90 ++++++++++++++-------------------- 2 files changed, 63 insertions(+), 64 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index bf975bf..6daa98e 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -144,19 +144,36 @@ def on_button_clicked(self, button): # You can also check the standard button type if button == self.buttonBox.button(QDialogButtonBox.Save): 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(): + QMessageBox.warning( + self, + "Warning", + "Please switch to 'Server' tab before opening a configuration file.", + ) + return + else: + 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) 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(): + QMessageBox.warning( + self, + "Warning", + "Please switch to 'Server' tab before opening a configuration file.", + ) + return + 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() diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui index 111ea1e..ddbfd48 100644 --- a/pygeoapi_config_dialog_base.ui +++ b/pygeoapi_config_dialog_base.ui @@ -29,9 +29,9 @@ 0 - 0 + -71 834 - 992 + 1021 @@ -2520,6 +2520,23 @@ + + + + Qt::RightToLeft + + + Server Connection + + + + + + + QDialogButtonBox::Close|QDialogButtonBox::Open|QDialogButtonBox::Save + + + @@ -2535,13 +2552,6 @@ - - - - QDialogButtonBox::Close|QDialogButtonBox::Open|QDialogButtonBox::Save - - - @@ -2551,23 +2561,7 @@ - - - buttonBox - clicked(QAbstractButton*) - PygeoapiConfigDialogBase - on_button_clicked() - - - 271 - 375 - - - 271 - 198 - - - + pushButtonBrowse clicked() @@ -2584,7 +2578,6 @@ - pushButtonBrowseTemplatesPath clicked() @@ -2601,7 +2594,6 @@ - addServerLangsButton clicked() @@ -2618,7 +2610,6 @@ - deleteServerLangsButton clicked() @@ -2635,7 +2626,6 @@ - addMetadataIdTitleButton clicked() @@ -2652,7 +2642,6 @@ - addMetadataIdDescriptionButton clicked() @@ -2669,7 +2658,6 @@ - addMetadataKeywordButton clicked() @@ -2686,8 +2674,6 @@ - - addResTitleButton clicked() @@ -2704,8 +2690,6 @@ - - addResDescriptionButton clicked() @@ -2722,8 +2706,6 @@ - - addResKeywordsButton clicked() @@ -2740,7 +2722,6 @@ - addResLinksButton clicked() @@ -2757,7 +2738,6 @@ - addResProviderButton clicked() @@ -2774,7 +2754,6 @@ - deleteMetadataIdTitleButton clicked() @@ -2791,7 +2770,6 @@ - deleteMetadataIdDescriptionButton clicked() @@ -2808,7 +2786,6 @@ - deleteResTitleButton clicked() @@ -2825,7 +2802,6 @@ - deleteResDescriptionButton clicked() @@ -2842,7 +2818,6 @@ - deleteResKeywordsButton clicked() @@ -2859,7 +2834,6 @@ - deleteResLinksButton clicked() @@ -2876,7 +2850,6 @@ - editResProviderButton clicked() @@ -2893,7 +2866,6 @@ - deleteResProviderButton clicked() @@ -2910,8 +2882,6 @@ - - deleteMetadataKeywordButton clicked() @@ -2928,7 +2898,6 @@ - pushButtonBrowseTemplatesStatic clicked() @@ -2945,7 +2914,6 @@ - lineEditCollection textChanged(QString) @@ -3026,7 +2994,6 @@ - pushSaveAndPreviewResource clicked() @@ -3059,7 +3026,6 @@ - validateResExtentsCrsButton clicked() @@ -3076,6 +3042,22 @@ + + buttonBox + clicked(QAbstractButton*) + PygeoapiConfigDialogBase + on_button_clicked() + + + 271 + 375 + + + 271 + 198 + + + open_logfile_dialog() From 49b80e57d211bafdd8e2d4f2b1cba3b171fbc22b Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 16:48:13 +0000 Subject: [PATCH 05/24] - added server config dialog --- pygeoapi_config.py | 1 + pygeoapi_config_dialog.py | 64 ++++++++++++---- pygeoapi_config_dialog_base.ui | 3 + server_config_dialog.py | 71 +++++++++++++++++ server_config_dialog.ui | 135 +++++++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 13 deletions(-) create mode 100644 server_config_dialog.py create mode 100644 server_config_dialog.ui 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 6daa98e..7cb031a 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -31,6 +31,7 @@ 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,13 +73,34 @@ except: pass +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_data(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() + return {'host': host, 'port': port} + # This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer FORM_CLASS, _ = uic.loadUiType( os.path.join(os.path.dirname(__file__), "pygeoapi_config_dialog_base.ui") ) - + class PygeoapiConfigDialog(QtWidgets.QDialog, FORM_CLASS): config_data: ConfigData @@ -146,12 +168,7 @@ def on_button_clicked(self, button): if self._set_validate_ui_data()[0]: if self.serverRadio.isChecked(): - QMessageBox.warning( - self, - "Warning", - "Please switch to 'Server' tab before opening a configuration file.", - ) - return + self.server_config(save=True) else: file_path, _ = QFileDialog.getSaveFileName( self, "Save File", "", "YAML Files (*.yml);;All Files (*)" @@ -163,12 +180,7 @@ def on_button_clicked(self, button): elif button == self.buttonBox.button(QDialogButtonBox.Open): if self.serverRadio.isChecked(): - QMessageBox.warning( - self, - "Warning", - "Please switch to 'Server' tab before opening a configuration file.", - ) - return + self.server_config(save=False) else: file_name, _ = QFileDialog.getOpenFileName( self, "Open File", "", "YAML Files (*.yml);;All Files (*)" @@ -177,6 +189,32 @@ def on_button_clicked(self, button): elif button == self.buttonBox.button(QDialogButtonBox.Close): self.reject() + return + + def server_config(self, save): + + dialog = ServerConfigDialog(self) + + if dialog.exec_(): + data = dialog.get_server_data() + if save == True: + self.push_to_server(data) + else: + self.pull_from_server(data) + + def push_to_server(self, data): + QMessageBox.warning( + self, + "Warning", + f"Pushing configuration to Host: {data['host']}, Port: {data['port']}", + ) + + def pull_from_server(self, data): + QMessageBox.warning( + self, + "Warning", + f"Pulling configuration from Host: {data['host']}, Port: {data['port']}", + ) def save_to_file(self, file_path): diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui index ddbfd48..012b938 100644 --- a/pygeoapi_config_dialog_base.ui +++ b/pygeoapi_config_dialog_base.ui @@ -2528,6 +2528,9 @@ Server Connection + + + diff --git a/server_config_dialog.py b/server_config_dialog.py new file mode 100644 index 0000000..15151df --- /dev/null +++ b/server_config_dialog.py @@ -0,0 +1,71 @@ +# -*- 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(379, 130) + self.verticalLayout = QtWidgets.QVBoxLayout(serverDialog) + self.verticalLayout.setObjectName("verticalLayout") + 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) + spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem) + self.verticalLayout.addLayout(self.horizontalLayout) + spacerItem1 = QtWidgets.QSpacerItem(20, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding) + self.verticalLayout.addItem(spacerItem1) + 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.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..5e52090 --- /dev/null +++ b/server_config_dialog.ui @@ -0,0 +1,135 @@ + + + serverDialog + + + + 0 + 0 + 379 + 130 + + + + Dialog + + + + + + + + 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 + + + + + From fbaf59453821ebf97e3cf47bec08be2428216d92 Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 17:00:40 +0000 Subject: [PATCH 06/24] - added option to specify server protocol --- pygeoapi_config_dialog.py | 15 +++++++------- server_config_dialog.py | 27 +++++++++++++++++++----- server_config_dialog.ui | 43 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 71 insertions(+), 14 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index 7cb031a..a148354 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -92,7 +92,8 @@ def get_server_data(self): """ host = self.ServerHostlineEdit.text() port = self.ServerSpinBox.value() - return {'host': host, 'port': port} + protocol = 'http' if self.radioHttp.isChecked() else 'https' + return {'host': host, 'port': port, 'protocol': protocol} # This loads your .ui file so that PyQt can populate your plugin with the elements from Qt Designer @@ -203,17 +204,17 @@ def server_config(self, save): self.pull_from_server(data) def push_to_server(self, data): - QMessageBox.warning( + QMessageBox.information( self, - "Warning", - f"Pushing configuration to Host: {data['host']}, Port: {data['port']}", + "Information", + f"Pushing configuration to: {data['protocol']}://{data['host']}:{data['port']}", ) def pull_from_server(self, data): - QMessageBox.warning( + QMessageBox.information( self, - "Warning", - f"Pulling configuration from Host: {data['host']}, Port: {data['port']}", + "Information", + f"Pulling configuration from: {data['protocol']}://{data['host']}:{data['port']}", ) def save_to_file(self, file_path): diff --git a/server_config_dialog.py b/server_config_dialog.py index 15151df..d27ecaa 100644 --- a/server_config_dialog.py +++ b/server_config_dialog.py @@ -14,9 +14,24 @@ class Ui_serverDialog(object): def setupUi(self, serverDialog): serverDialog.setObjectName("serverDialog") - serverDialog.resize(379, 130) + 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) @@ -37,11 +52,11 @@ def setupUi(self, serverDialog): self.ServerSpinBox.setProperty("value", 5000) self.ServerSpinBox.setObjectName("ServerSpinBox") self.horizontalLayout.addWidget(self.ServerSpinBox) - spacerItem = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) - self.horizontalLayout.addItem(spacerItem) + spacerItem1 = QtWidgets.QSpacerItem(40, 20, QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Minimum) + self.horizontalLayout.addItem(spacerItem1) self.verticalLayout.addLayout(self.horizontalLayout) - spacerItem1 = QtWidgets.QSpacerItem(20, 1, QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.MinimumExpanding) - self.verticalLayout.addItem(spacerItem1) + 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) @@ -56,6 +71,8 @@ def setupUi(self, 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")) diff --git a/server_config_dialog.ui b/server_config_dialog.ui index 5e52090..e75c511 100644 --- a/server_config_dialog.ui +++ b/server_config_dialog.ui @@ -6,14 +6,53 @@ 0 0 - 379 - 130 + 382 + 173 Dialog + + + + + + + + + + http + + + true + + + + + + + https + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + From 76e795b93ae88e6682932008ba4eccd5519e430b Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 8 Dec 2025 17:37:11 +0000 Subject: [PATCH 07/24] - added support for pushing and pulling configiration from a pygeoapi instance --- pygeoapi_config_dialog.py | 108 +++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 14 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index a148354..a20198d 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -23,8 +23,10 @@ """ from copy import deepcopy -from datetime import datetime, timezone +from datetime import date, datetime, timezone import os +from wsgiref import headers +import requests import yaml from .utils.data_diff import diff_yaml_dict @@ -73,6 +75,21 @@ except: pass +headers = { + 'accept': '*/*', + 'Content-Type': 'application/json' +} + +def preprocess_for_json(d): + """Recursively converts datetime/date objects in a dict to ISO strings.""" + if isinstance(d, dict): + return {k: preprocess_for_json(v) for k, v in d.items()} + elif isinstance(d, list): + return [preprocess_for_json(i) for i in d] + elif isinstance(d, (datetime, date)): + return d.isoformat() + return d + class ServerConfigDialog(QDialog, Ui_serverDialog): """ Logic for the Server Configuration Dialog. @@ -196,26 +213,89 @@ def server_config(self, save): dialog = ServerConfigDialog(self) - if dialog.exec_(): + if dialog.exec_(): data = dialog.get_server_data() + url = f"{data['protocol']}://{data['host']}:{data['port']}/admin/config" if save == True: - self.push_to_server(data) + self.push_to_server(url) else: - self.pull_from_server(data) + self.pull_from_server(url) - def push_to_server(self, data): - QMessageBox.information( - self, - "Information", - f"Pushing configuration to: {data['protocol']}://{data['host']}:{data['port']}", + def push_to_server(self, url): + + QMessageBox.information( + self, + "Information", + f"Pushing configuration to: {url}", + ) + + config_dict = self.config_data.asdict_enum_safe(self.config_data) + + # Pre-process the dictionary to handle datetime objects + processed_config_dict = preprocess_for_json(config_dict) + + # TODO: support authentication through the QT framework + try: + # Send the PUT request to Admin API + response = requests.put(url, headers=headers, json=processed_config_dict) + response.raise_for_status() + + QgsMessageLog.logMessage( + f"Success! Status Code: {response.status_code}") + + except requests.exceptions.RequestException as e: + QgsMessageLog.logMessage(f"An error occurred: {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}") + + QgsMessageLog.logMessage( + f"Response: {response.text}") + + data_dict = response.json() + + self.config_data = ConfigData() + self.config_data.set_data_from_yaml(data_dict) + self.ui_setter.set_ui_from_data() + + # log messages about missing or mistyped values during deserialization + 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}" ) - def pull_from_server(self, data): - QMessageBox.information( - self, - "Information", - f"Pulling configuration from: {data['protocol']}://{data['host']}:{data['port']}", + # 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: {self.config_data.all_missing_props}" ) + if len(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 requests.exceptions.RequestException as e: + QgsMessageLog.logMessage(f"An error occurred: {e}") def save_to_file(self, file_path): From 3bc869a81910c1e964307d45b8ee793fbcdd9133 Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 15 Dec 2025 12:28:50 +0000 Subject: [PATCH 08/24] - added another radio for local file option --- pygeoapi_config_dialog_base.ui | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/pygeoapi_config_dialog_base.ui b/pygeoapi_config_dialog_base.ui index 012b938..8177d28 100644 --- a/pygeoapi_config_dialog_base.ui +++ b/pygeoapi_config_dialog_base.ui @@ -29,9 +29,9 @@ 0 - -71 + -96 834 - 1021 + 1050 @@ -2520,6 +2520,19 @@ + + + + Qt::RightToLeft + + + Local File + + + true + + + @@ -2528,9 +2541,6 @@ Server Connection - - - From 93ba679721bc1e095c8c16f6a3732f53c2786373 Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 15 Dec 2025 12:34:31 +0000 Subject: [PATCH 09/24] - added server_config_dialog.ui to pb_tool configuration --- pb_tool.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b1cc03fd20c334073caf2358389e81c3109c94ee Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 15 Dec 2025 13:17:39 +0000 Subject: [PATCH 10/24] - added more expressive error messages --- pygeoapi_config_dialog.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index a20198d..1a49425 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -243,8 +243,19 @@ def push_to_server(self, url): 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 pulling the configuration from the server: {e}", + ) def pull_from_server(self, url): @@ -264,6 +275,12 @@ def pull_from_server(self, url): 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}") @@ -297,6 +314,13 @@ def pull_from_server(self, url): 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, file_path): if file_path: From 17f09ad526780a374d9cf2396a7dd7f5f269a6b7 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Wed, 17 Dec 2025 21:22:30 +0000 Subject: [PATCH 11/24] remove redundant data import functionality; make sure to check diff on Save to Server --- pygeoapi_config_dialog.py | 161 +++++++++++++++++--------------------- 1 file changed, 72 insertions(+), 89 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index 1a49425..bf7dd9c 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -33,7 +33,7 @@ from .ui_widgets.utils import get_url_status -from .server_config_dialog import Ui_serverDialog +from .server_config_dialog import Ui_serverDialog from .models.top_level.providers.records import ProviderTypes from .ui_widgets.providers.NewProviderWindow import NewProviderWindow @@ -75,10 +75,8 @@ except: pass -headers = { - 'accept': '*/*', - 'Content-Type': 'application/json' -} +headers = {"accept": "*/*", "Content-Type": "application/json"} + def preprocess_for_json(d): """Recursively converts datetime/date objects in a dict to ISO strings.""" @@ -90,27 +88,29 @@ def preprocess_for_json(d): return d.isoformat() return d + 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 - + 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_data(self): + 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 {'host': host, 'port': port, 'protocol': protocol} + 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 @@ -118,7 +118,7 @@ def get_server_data(self): os.path.join(os.path.dirname(__file__), "pygeoapi_config_dialog_base.ui") ) - + class PygeoapiConfigDialog(QtWidgets.QDialog, FORM_CLASS): config_data: ConfigData @@ -183,17 +183,25 @@ 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]: if self.serverRadio.isChecked(): + # check #1: show diff with "Procced" and "Cancel" options + if not self._diff_original_and_current_data(): + return + self.server_config(save=True) else: + # check #1: show diff with "Procced" and "Cancel" options + if not self._diff_original_and_current_data(): + return + 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(): + # check #2: valid file path + if file_path: self.save_to_file(file_path) elif button == self.buttonBox.button(QDialogButtonBox.Open): @@ -213,14 +221,13 @@ def server_config(self, save): dialog = ServerConfigDialog(self) - if dialog.exec_(): - data = dialog.get_server_data() - url = f"{data['protocol']}://{data['host']}:{data['port']}/admin/config" - if save == True: + if dialog.exec_(): + url = dialog.get_server_url() + if save: self.push_to_server(url) else: - self.pull_from_server(url) - + self.pull_from_server(url) + def push_to_server(self, url): QMessageBox.information( @@ -240,8 +247,7 @@ def push_to_server(self, url): response = requests.put(url, headers=headers, json=processed_config_dict) response.raise_for_status() - QgsMessageLog.logMessage( - f"Success! Status Code: {response.status_code}") + QgsMessageLog.logMessage(f"Success! Status Code: {response.status_code}") QMessageBox.information( self, @@ -257,9 +263,8 @@ def push_to_server(self, url): f"An error occurred pulling the configuration from the server: {e}", ) - def pull_from_server(self, url): - + QMessageBox.information( self, "Information", @@ -272,8 +277,7 @@ def pull_from_server(self, url): response = requests.get(url, headers=headers) response.raise_for_status() - QgsMessageLog.logMessage( - f"Success! Status Code: {response.status_code}") + QgsMessageLog.logMessage(f"Success! Status Code: {response.status_code}") QMessageBox.information( self, @@ -281,35 +285,10 @@ def pull_from_server(self, url): f"Success! Status Code: {response.status_code}", ) - QgsMessageLog.logMessage( - f"Response: {response.text}") + QgsMessageLog.logMessage(f"Response: {response.text}") - data_dict = response.json() - - self.config_data = ConfigData() - self.config_data.set_data_from_yaml(data_dict) - self.ui_setter.set_ui_from_data() - - # log messages about missing or mistyped values during deserialization - 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}" - ) - - # 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: {self.config_data.all_missing_props}" - ) - if len(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_() + data_dict = response.json() + self.update_config_data_and_ui(data_dict) except requests.exceptions.RequestException as e: QgsMessageLog.logMessage(f"An error occurred: {e}") @@ -320,7 +299,6 @@ def pull_from_server(self, url): f"An error occurred pulling the configuration from the server: {e}", ) - def save_to_file(self, file_path): if file_path: @@ -360,48 +338,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) + + self.update_config_data_and_ui(yaml_original_data_dict) - # reset data - self.config_data = ConfigData() + except Exception as e: + QMessageBox.warning(self, "Error", f"Cannot open file:\n{str(e)}") + # finally: + # QApplication.restoreOverrideCursor() - # set data and .all_missing_props: - yaml_original_data = yaml.safe_load(file_content) - self.yaml_original_data = deepcopy(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.""" - self.config_data.set_data_from_yaml(yaml_original_data) + # reset data + self.config_data = ConfigData() - # set UI from data - self.ui_setter.set_ui_from_data() + # set data and .all_missing_props: + self.yaml_original_data = deepcopy(data_dict) + self.config_data.set_data_from_yaml(data_dict) - # 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 UI from data + self.ui_setter.set_ui_from_data() - # 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}" - ) + # 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}" + ) - 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 + # 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}" + ) - except Exception as e: - QMessageBox.warning(self, "Error", f"Cannot open file:\n{str(e)}") - # finally: - # QApplication.restoreOverrideCursor() + 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 From 9bd10af9ad6a661a70aa13406aaf1dd4cd9551d3 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Wed, 17 Dec 2025 21:25:50 +0000 Subject: [PATCH 12/24] remove redundant datetime conversion --- pygeoapi_config_dialog.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index bf7dd9c..1237522 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -78,17 +78,6 @@ headers = {"accept": "*/*", "Content-Type": "application/json"} -def preprocess_for_json(d): - """Recursively converts datetime/date objects in a dict to ISO strings.""" - if isinstance(d, dict): - return {k: preprocess_for_json(v) for k, v in d.items()} - elif isinstance(d, list): - return [preprocess_for_json(i) for i in d] - elif isinstance(d, (datetime, date)): - return d.isoformat() - return d - - class ServerConfigDialog(QDialog, Ui_serverDialog): """ Logic for the Server Configuration Dialog. @@ -190,7 +179,7 @@ def on_button_clicked(self, button): # check #1: show diff with "Procced" and "Cancel" options if not self._diff_original_and_current_data(): return - + self.server_config(save=True) else: # check #1: show diff with "Procced" and "Cancel" options @@ -236,10 +225,9 @@ def push_to_server(self, url): f"Pushing configuration to: {url}", ) - config_dict = self.config_data.asdict_enum_safe(self.config_data) - - # Pre-process the dictionary to handle datetime objects - processed_config_dict = preprocess_for_json(config_dict) + processed_config_dict = self.config_data.asdict_enum_safe( + self.config_data, datetime_to_str=True + ) # TODO: support authentication through the QT framework try: From 602cf2a7e05919835c4bbb477cfff9cb7f55db42 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Thu, 18 Dec 2025 14:58:02 +0000 Subject: [PATCH 13/24] centralize stringifying datetime strings for Diff and Save --- pygeoapi_config_dialog.py | 62 +++++++++++++++++++-------------------- tests/test_yaml_save.py | 10 +++++-- 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index 1237522..8e6f8c8 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -149,14 +149,6 @@ class CustomDumper(yaml.SafeDumper): ), ) - def represent_datetime_as_timestamp(dumper, data: datetime): - value = self.config_data.datetime_to_string(data) - - # emit as YAML timestamp → plain scalar, no quotes - return dumper.represent_scalar("tag:yaml.org,2002:timestamp", value) - - self.dumper.add_representer(datetime, represent_datetime_as_timestamp) - # custom assignments self.model = QStringListModel() self.proxy = QSortFilterProxyModel() @@ -177,13 +169,19 @@ def on_button_clicked(self, button): if self.serverRadio.isChecked(): # check #1: show diff with "Procced" and "Cancel" options - if not self._diff_original_and_current_data(): + diff_approved, processed_config_data = ( + self._diff_original_and_current_data() + ) + if not diff_approved: return - self.server_config(save=True) + self.server_config(data_to_push=processed_config_data) else: # check #1: show diff with "Procced" and "Cancel" options - if not self._diff_original_and_current_data(): + diff_approved, processed_config_data = ( + self._diff_original_and_current_data() + ) + if not diff_approved: return file_path, _ = QFileDialog.getSaveFileName( @@ -191,11 +189,11 @@ def on_button_clicked(self, button): ) # check #2: valid file path if file_path: - self.save_to_file(file_path) + self.save_to_file(processed_config_data, file_path) elif button == self.buttonBox.button(QDialogButtonBox.Open): if self.serverRadio.isChecked(): - self.server_config(save=False) + self.server_config(data_to_push=None) else: file_name, _ = QFileDialog.getOpenFileName( self, "Open File", "", "YAML Files (*.yml);;All Files (*)" @@ -206,18 +204,18 @@ def on_button_clicked(self, button): self.reject() return - def server_config(self, save): + def server_config(self, data_to_push: dict | None = None): dialog = ServerConfigDialog(self) if dialog.exec_(): url = dialog.get_server_url() - if save: - self.push_to_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): + def push_to_server(self, url, data_to_push: dict): QMessageBox.information( self, @@ -225,14 +223,10 @@ def push_to_server(self, url): f"Pushing configuration to: {url}", ) - processed_config_dict = self.config_data.asdict_enum_safe( - self.config_data, datetime_to_str=True - ) - # TODO: support authentication through the QT framework try: # Send the PUT request to Admin API - response = requests.put(url, headers=headers, json=processed_config_dict) + response = requests.put(url, headers=headers, json=data_to_push) response.raise_for_status() QgsMessageLog.logMessage(f"Success! Status Code: {response.status_code}") @@ -248,7 +242,7 @@ def push_to_server(self, url): QMessageBox.critical( self, "Error", - f"An error occurred pulling the configuration from the server: {e}", + f"An error occurred pushing the configuration to the server: {e}", ) def pull_from_server(self, url): @@ -287,14 +281,14 @@ def pull_from_server(self, url): f"An error occurred pulling the configuration from the server: {e}", ) - def save_to_file(self, file_path): + 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, @@ -402,14 +396,20 @@ 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) -> 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 ( @@ -418,7 +418,7 @@ def _diff_original_and_current_data(self) -> tuple[bool, list]: + len(diff_data["changed"]) == 0 ): - return True + return True, new_config_data # add a window with the choice QgsMessageLog.logMessage(f"{diff_data}") @@ -426,9 +426,9 @@ def _diff_original_and_current_data(self) -> tuple[bool, list]: 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.""" diff --git a/tests/test_yaml_save.py b/tests/test_yaml_save.py index c5d6f48..d0d8c87 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=True + ) 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( [ @@ -103,8 +106,11 @@ def test_open_file_validate_ui_data_save_file(qtbot, sample_yaml: str): 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=True + ) 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 From 33e379decfb7bfb97db5e63e7a28e9df2490a776 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 19 Dec 2025 14:19:36 +0000 Subject: [PATCH 14/24] accept +0000 format datetime (comes from requests) --- models/ConfigData.py | 13 +++-------- models/top_level/utils.py | 47 +++++++++++++++++++++++++++++++++++++++ models/utils.py | 20 ++++++++--------- 3 files changed, 59 insertions(+), 21 deletions(-) diff --git a/models/ConfigData.py b/models/ConfigData.py index ab6ecba..3384596 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 .top_level.utils 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..972d627 100644 --- a/models/top_level/utils.py +++ b/models/top_level/utils.py @@ -1,5 +1,6 @@ from datetime import datetime, timezone from enum import Enum +import re STRING_SEPARATOR = " | " @@ -64,3 +65,49 @@ def to_iso8601(dt: datetime) -> str: dt = dt.astimezone(timezone.utc) return dt.strftime("%Y-%m-%dT%H:%M:%SZ") + + +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 not isinstance(value, str): + return None + + s = value.strip() + # quick normalization: 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 first (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 diff --git a/models/utils.py b/models/utils.py index c7eee06..42684be 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, + 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) From e68ee939d3d9385146ce38d8c7d461ca440e3c58 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 19 Dec 2025 14:27:44 +0000 Subject: [PATCH 15/24] ignore datetime diffs for different datetime formats --- utils/data_diff.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/utils/data_diff.py b/utils/data_diff.py index 81f85ad..ef2d77c 100644 --- a/utils/data_diff.py +++ b/utils/data_diff.py @@ -74,6 +74,15 @@ def diff_obj(obj1: Any, obj2: Any, diff: dict, path: str = "") -> dict: else: if obj1 != obj2: + + # 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") + ): + return diff + diff["changed"][path] = {"old": obj1, "new": obj2} return diff From 7c7ffc8f864e3052706f87a305b529ba0dd68fa7 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 19 Dec 2025 14:45:38 +0000 Subject: [PATCH 16/24] consider case where datetime was parsed from the beginning (was never a string) --- models/ConfigData.py | 2 +- models/top_level/utils.py | 61 --------------------------------------- models/utils.py | 2 +- utils/data_diff.py | 11 +++++++ utils/helper_functions.py | 55 +++++++++++++++++++++++++++++++++++ 5 files changed, 68 insertions(+), 63 deletions(-) create mode 100644 utils/helper_functions.py diff --git a/models/ConfigData.py b/models/ConfigData.py index 3384596..dccb56a 100644 --- a/models/ConfigData.py +++ b/models/ConfigData.py @@ -9,7 +9,7 @@ MetadataConfig, ResourceConfigTemplate, ) -from .top_level.utils import datetime_to_string +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 diff --git a/models/top_level/utils.py b/models/top_level/utils.py index 972d627..36b8087 100644 --- a/models/top_level/utils.py +++ b/models/top_level/utils.py @@ -1,6 +1,5 @@ from datetime import datetime, timezone from enum import Enum -import re STRING_SEPARATOR = " | " @@ -51,63 +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") - - -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 not isinstance(value, str): - return None - - s = value.strip() - # quick normalization: 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 first (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 diff --git a/models/utils.py b/models/utils.py index 42684be..f92a5ba 100644 --- a/models/utils.py +++ b/models/utils.py @@ -7,8 +7,8 @@ from .top_level.utils import ( InlineList, get_enum_value_from_string, - datetime_from_string, ) +from ..utils.helper_functions import datetime_from_string def update_dataclass_from_dict( diff --git a/utils/data_diff.py b/utils/data_diff.py index ef2d77c..5aa3a8e 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.""" @@ -75,6 +78,14 @@ 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 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 From 079738910f06d12757da31a942db7bc5be6279a6 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 19 Dec 2025 15:26:14 +0000 Subject: [PATCH 17/24] bring back yaml representer removing string quotes from datetime objects --- pygeoapi_config_dialog.py | 28 ++++++++++++++++++++++++---- tests/test_yaml_save.py | 8 ++++---- 2 files changed, 28 insertions(+), 8 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index 8e6f8c8..407c8ea 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -23,12 +23,13 @@ """ from copy import deepcopy -from datetime import date, 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 @@ -149,6 +150,15 @@ class CustomDumper(yaml.SafeDumper): ), ) + # make sure datetime items are not saved as strings with quotes + def represent_datetime_as_timestamp(dumper, data: datetime): + value = datetime_to_string(data) + + # emit as YAML timestamp → plain scalar, no quotes + return dumper.represent_scalar("tag:yaml.org,2002:timestamp", value) + + self.dumper.add_representer(datetime, represent_datetime_as_timestamp) + # custom assignments self.model = QStringListModel() self.proxy = QSortFilterProxyModel() @@ -179,7 +189,7 @@ def on_button_clicked(self, button): else: # check #1: show diff with "Procced" and "Cancel" options diff_approved, processed_config_data = ( - self._diff_original_and_current_data() + self._diff_original_and_current_data(get_yaml_output=True) ) if not diff_approved: return @@ -396,7 +406,9 @@ 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, dict]: + 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( @@ -412,6 +424,14 @@ def _diff_original_and_current_data(self) -> tuple[bool, dict]: 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"]) @@ -420,7 +440,7 @@ def _diff_original_and_current_data(self) -> tuple[bool, dict]: ): 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) diff --git a/tests/test_yaml_save.py b/tests/test_yaml_save.py index d0d8c87..0cae86b 100644 --- a/tests/test_yaml_save.py +++ b/tests/test_yaml_save.py @@ -42,7 +42,7 @@ def test_json_schema_on_open_save(qtbot, sample_yaml: str): # Save YAML processed_config_data = dialog.config_data.asdict_enum_safe( - dialog.config_data, datetime_to_str=True + dialog.config_data, datetime_to_str=False ) abs_new_yaml_path = sample_yaml.with_name(f"saved_{sample_yaml.name}") dialog.save_to_file(processed_config_data, abs_new_yaml_path) @@ -101,13 +101,13 @@ 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=True + 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(processed_config_data, abs_new_yaml_path) @@ -115,7 +115,7 @@ def test_open_file_validate_ui_data_save_file(qtbot, sample_yaml: str): # 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 From a5f584af47a6f9be40a0f86f8aa2e6ae2e937a33 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 19 Dec 2025 16:03:48 +0000 Subject: [PATCH 18/24] ensure utf-8 encoding on push_to_server --- pygeoapi_config_dialog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index 407c8ea..6df055e 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -76,7 +76,7 @@ except: pass -headers = {"accept": "*/*", "Content-Type": "application/json"} +headers = {"accept": "*/*", "Content-Type": "application/json; charset=utf-8"} class ServerConfigDialog(QDialog, Ui_serverDialog): From 280df834bf052d825edb9b70f0c6e3bc68f9614d Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 19 Dec 2025 16:18:05 +0000 Subject: [PATCH 19/24] typo --- utils/data_diff.py | 1 + 1 file changed, 1 insertion(+) diff --git a/utils/data_diff.py b/utils/data_diff.py index 5aa3a8e..6ce7371 100644 --- a/utils/data_diff.py +++ b/utils/data_diff.py @@ -91,6 +91,7 @@ def diff_obj(obj1: Any, obj2: Any, diff: dict, path: str = "") -> dict: type(obj1) == type(obj2) == str and obj2.endswith("Z") and obj1.endswith("+00:00") + and obj2[:-1] == obj1[:-6] ): return diff From 375338d7ba7c1affec1cbcbe191a91dafecc5a2f Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 29 Dec 2025 11:51:03 +0000 Subject: [PATCH 20/24] - set test plattform to Windows - add GUI dependencies for (headless) unit tests --- .github/workflows/unit-tests.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 77f712b..f83fd62 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 @@ -24,6 +24,26 @@ jobs: 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 From b70e1114d4a36b575c68f72a71d62d6e10c3a6df Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 29 Dec 2025 14:16:12 +0000 Subject: [PATCH 21/24] - added unit test for server push and pull --- tests/test_server_push.py | 91 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 91 insertions(+) create mode 100644 tests/test_server_push.py diff --git a/tests/test_server_push.py b/tests/test_server_push.py new file mode 100644 index 0000000..3c89ff0 --- /dev/null +++ b/tests/test_server_push.py @@ -0,0 +1,91 @@ +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 + +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." + From 5b1ed999b5a1aca503d1cfb019349cd092eb69f3 Mon Sep 17 00:00:00 2001 From: doublebyte Date: Mon, 29 Dec 2025 14:37:46 +0000 Subject: [PATCH 22/24] - Added GitHub action to test the server round trip --- .github/workflows/unit-tests.yml | 17 +++++++++++++++++ tests/test_server_push.py | 4 ++++ 2 files changed, 21 insertions(+) diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index f83fd62..5a71c9c 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -18,6 +18,23 @@ 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 diff --git a/tests/test_server_push.py b/tests/test_server_push.py index 3c89ff0..b22f3bb 100644 --- a/tests/test_server_push.py +++ b/tests/test_server_push.py @@ -5,6 +5,10 @@ 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 From 292e445fb7ab9a141e22eb29207c370e78affae8 Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Wed, 31 Dec 2025 00:38:01 +0000 Subject: [PATCH 23/24] use default language for Metadata and Resources dict --- ui_widgets/UiSetter.py | 20 +++++++++++++++----- ui_widgets/ui_setter_utils.py | 19 ++++++++++++++++--- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/ui_widgets/UiSetter.py b/ui_widgets/UiSetter.py index ca398d7..2bd4e16 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( 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) From bec3a7d224f4739c9f5e3fc968d23a7027fad8bd Mon Sep 17 00:00:00 2001 From: Kateryna Konieva Date: Fri, 2 Jan 2026 14:01:39 +0000 Subject: [PATCH 24/24] confirm before deleting resource --- pygeoapi_config_dialog.py | 23 ++++++++++++++++++----- ui_widgets/UiSetter.py | 23 +++++++++++++---------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/pygeoapi_config_dialog.py b/pygeoapi_config_dialog.py index 6df055e..c59c42c 100644 --- a/pygeoapi_config_dialog.py +++ b/pygeoapi_config_dialog.py @@ -706,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.""" @@ -739,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/ui_widgets/UiSetter.py b/ui_widgets/UiSetter.py index 2bd4e16..697dd2f 100644 --- a/ui_widgets/UiSetter.py +++ b/ui_widgets/UiSetter.py @@ -596,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: @@ -616,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